Skip to main content

release_kit/release_engine/
request.rs

1//! `rk release request`: keep the one release request of the pushed base.
2//!
3//! It runs on a push to the trunk or to a release line. It reads the
4//! version through the adapter and proposes the next one. After a release
5//! it proposes one only where the release packs a different set of files
6//! than at the release tag or a file it packs changed since, and git-cliff
7//! reads only the commits that touch those files. Where nothing remains to release it closes a stale request, and
8//! otherwise it writes the version and the changelog on
9//! `rk-release/<base>`, force-pushes it as the bot, and opens or edits the
10//! request. It never arms or merges it: a release waits for the operator's
11//! order.
12
13use camino::Utf8Path;
14use serde::Serialize;
15
16use crate::diagnostic::{Diagnostic, Reason};
17use crate::error::RkError;
18
19use super::adapter::Version;
20use super::forge::Client;
21use super::guard::{self, Proposal};
22use super::packed::{self, Comparison};
23use super::{Context, Done, cliff, eligible};
24
25/// The report's shape version.
26pub const SCHEMA: &str = "rk.release-request/2";
27
28/// The machine form of `rk release request`.
29#[derive(Debug, Serialize)]
30pub struct RequestReport {
31    /// The shape version of this document.
32    pub schema: &'static str,
33    /// `opened`, `refreshed`, `closed`, `nothing`, or `skip`.
34    pub state: &'static str,
35    /// Why nothing was proposed, where nothing was.
36    #[serde(skip_serializing_if = "Option::is_none")]
37    pub reason: Option<String>,
38    /// The base the request merges into.
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub base: Option<String>,
41    /// The version the base carries, in the driver's spelling.
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub version: Option<String>,
44    /// The last release tag the packed files were compared with, where
45    /// the request compared them.
46    #[serde(skip_serializing_if = "Option::is_none")]
47    pub compared: Option<String>,
48    /// How many files the release packs at `HEAD` or at that tag.
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub packed: Option<usize>,
51    /// The version the request proposes, in the driver's spelling.
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub proposal: Option<String>,
54    /// The tag the proposal releases as.
55    #[serde(skip_serializing_if = "Option::is_none")]
56    pub tag: Option<String>,
57    /// The request's number on the forge.
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub request: Option<u64>,
60    /// Every file the request commit changed.
61    pub files: Vec<String>,
62}
63
64impl RequestReport {
65    const fn empty(state: &'static str) -> Self {
66        Self {
67            schema: SCHEMA,
68            state,
69            reason: None,
70            base: None,
71            version: None,
72            compared: None,
73            packed: None,
74            proposal: None,
75            tag: None,
76            request: None,
77            files: Vec::new(),
78        }
79    }
80}
81
82/// Keep the release request of the pushed base.
83///
84/// # Errors
85///
86/// A refusal of the default CI token, of git-cliff off its pin, or of a
87/// prerelease proposal; a forge or a push failure.
88pub fn run(target: &Utf8Path) -> Result<Done<RequestReport>, RkError> {
89    let context = Context::load(target)?;
90    refuse_default_token(&context)?;
91    context.require_tools("request")?;
92    let Some(base) = context
93        .pushed_branch()
94        .filter(|branch| context.is_base(branch))
95    else {
96        let mut report = RequestReport::empty("skip");
97        report.reason = Some("the push is to no release base".to_owned());
98        return Ok(Done {
99            lines: vec!["skip: the push is to neither the trunk nor a release line".to_owned()],
100            report,
101            next: Vec::new(),
102        });
103    };
104    cliff::require_pin(target)?;
105    let client = Client::resolve(context.forge, target, &context.repo)?;
106    let current = context.adapter.read_version(target)?;
107    let branch = super::request_branch(&base);
108    let (proposal, comparison) = decide(&context, &client, target, current)?;
109    let mut report = RequestReport::empty("nothing");
110    report.base = Some(base.clone());
111    report.version = Some(current.spelled(context.driver()));
112    let compared = record_comparison(&mut report, comparison.as_ref());
113    let paths = comparison
114        .map(|comparison| comparison.history)
115        .unwrap_or_default();
116    let next = match proposal {
117        Proposal::Refuse(reason) => {
118            return Err(RkError::refusal(
119                Diagnostic::new(Reason::StateDrift, reason)
120                    .expected("a final version for the request to propose")
121                    .action("rk lines mint numbers a candidate; the request proposes final versions alone")
122                    .target_state("unchanged"),
123            ));
124        }
125        Proposal::Nothing(reason) => {
126            let mut done = close_stale(&context, &client, &base, report, reason)?;
127            done.lines.splice(0..0, compared);
128            return Ok(done);
129        }
130        Proposal::Propose(next) => next,
131    };
132    let tag = context.adapter.tag_of(next);
133    let files = commit_request(&context, &client, next, &tag, &branch, &paths)?;
134    let title = format!("chore(release): {tag}");
135    let body = super::forge_text(
136        "blocks/release-request-body.md.in",
137        &[("RK_TAG", &tag), ("RK_BASE", &base)],
138    )?;
139    let (number, state) = match client.open_request(&base, &branch)? {
140        Some(number) => {
141            client.edit_request(number, &title, &body)?;
142            (number, "refreshed")
143        }
144        None => (
145            client.create_request(&base, &branch, &title, &body)?,
146            "opened",
147        ),
148    };
149    // SATISFIES release-engine:the-request-never-arms
150    let mut lines = compared;
151    lines.push(format!(
152        "{state} release request #{number} proposing {} on {branch} into {base}",
153        next.spelled(context.driver())
154    ));
155    lines.push("not armed: the request waits for the operator's order to merge".to_owned());
156    report.state = state;
157    report.proposal = Some(next.spelled(context.driver()));
158    report.tag = Some(tag);
159    report.request = Some(number);
160    report.files = files;
161    Ok(Done {
162        report,
163        lines,
164        next: Vec::new(),
165    })
166}
167
168/// Refuse where the push credential is the forge's default CI token.
169fn refuse_default_token(context: &Context) -> Result<(), RkError> {
170    let Some(var) = super::default_ci_token(context.forge, |name| std::env::var(name).ok()) else {
171        return Ok(());
172    };
173    Err(RkError::refusal(
174        Diagnostic::new(
175            Reason::DefaultCiToken,
176            format!(
177                "the push credential is {var}, the forge's default CI token, and a push with it starts no pipeline"
178            ),
179        )
180        .expected(match context.forge {
181            crate::detect::Forge::Github => "GH_TOKEN set to the release App's token",
182            crate::detect::Forge::Gitlab => "GITLAB_TOKEN set from RELEASE_BOT_TOKEN",
183        })
184        .target_state("unchanged; nothing was pushed"),
185    ))
186}
187
188/// Put the comparison of the packed files into the report, and answer the
189/// human line that states it.
190fn record_comparison(report: &mut RequestReport, comparison: Option<&Comparison>) -> Vec<String> {
191    let Some(comparison) = comparison else {
192        return Vec::new();
193    };
194    report.compared = Some(comparison.tag.clone());
195    report.packed = Some(comparison.paths.len());
196    vec![format!(
197        "compared the {} files the release packs with {}",
198        comparison.paths.len(),
199        comparison.tag
200    )]
201}
202
203/// Whether the forge holds a published release of `version`: a release
204/// that is not a draft. A tag alone is no release, because a failed
205/// release save on GitLab leaves its tag behind, and a GitHub draft is
206/// unpublished. A read that failed refuses, because a release nobody read
207/// may exist.
208fn released(context: &Context, client: &Client, version: Version) -> Result<bool, RkError> {
209    // SATISFIES release-engine:the-request-never-numbers-a-candidate
210    Ok(client
211        .release(&context.adapter.tag_of(version))?
212        .is_some_and(|release| !release.draft))
213}
214
215/// The proposal for the version the base carries, with the comparison of
216/// the packed files where the version already released.
217///
218/// SATISFIES release-engine:a-request-follows-the-packed-files
219/// SATISFIES release-engine:a-request-needs-a-releasing-commit
220///
221/// A released version proposes only where the packed set or a packed file
222/// differs from its tag and a commit that touched the packed set since is
223/// a releasing commit. git-cliff then reads only the commits that touch
224/// the packed files, and the configuration where its declaration changed
225/// the set. A version with no release is judged the same way since the
226/// last stable release tag, and keeps its declared number once that holds.
227/// With no stable release tag at all, the first release proposes only
228/// where a commit of the whole history that touched the packed files at
229/// `HEAD` is a releasing commit, and then as it always did.
230fn decide(
231    context: &Context,
232    client: &Client,
233    target: &Utf8Path,
234    current: Version,
235) -> Result<(Proposal, Option<Comparison>), RkError> {
236    if current.is_candidate() {
237        return Ok((guard::propose(current, false, None), None));
238    }
239    let was_released = released(context, client, current)?;
240    let tag = context.adapter.tag_of(current);
241    // A version with no release, set by hand past an earlier release or
242    // promoted from a candidate, is judged since the last stable release,
243    // and keeps its declared version once that judgment holds.
244    let since = if was_released {
245        Some(tag)
246    } else {
247        packed::stable_tag_below(target, Some(&tag))?
248    };
249    let (verdict, comparison) = eligible::engine(context, since.as_deref())?;
250    if !verdict.eligible {
251        return Ok((Proposal::Nothing(verdict.reason), comparison));
252    }
253    let answer = if guard::needs_cliff(current, was_released) {
254        let paths = comparison
255            .as_ref()
256            .map(|compared| compared.history.clone())
257            .unwrap_or_default();
258        Some(cliff::bumped(target, &paths)?)
259    } else {
260        None
261    };
262    let proposal = guard::propose(current, was_released, answer.as_deref());
263    if let Proposal::Propose(next) = proposal
264        && next != current
265        && released(context, client, next)?
266    {
267        return Ok((
268            Proposal::Nothing(format!("{} is already released", next.tag())),
269            comparison,
270        ));
271    }
272    Ok((proposal, comparison))
273}
274
275/// Close the open request of `base`, where one is open, and delete its
276/// branch: nothing remains for it to release.
277fn close_stale(
278    context: &Context,
279    client: &Client,
280    base: &str,
281    mut report: RequestReport,
282    reason: String,
283) -> Result<Done<RequestReport>, RkError> {
284    let branch = super::request_branch(base);
285    let mut lines = vec![format!("nothing to release: {reason}")];
286    if let Some(number) = client.open_request(base, &branch)? {
287        client.close_request(number)?;
288        let held = super::git_ok(
289            &context.target,
290            &["ls-remote", "--heads", "origin", &branch],
291            "an origin remote git can read",
292        )?;
293        if !held.trim().is_empty() {
294            super::push(context, &["--delete", &branch])?;
295        }
296        lines.push(format!(
297            "closed the stale release request #{number} and deleted {branch}"
298        ));
299        report.state = "closed";
300        report.request = Some(number);
301    }
302    report.reason = Some(reason);
303    Ok(Done {
304        report,
305        lines,
306        next: Vec::new(),
307    })
308}
309
310/// Write the version and the changelog on a fresh `branch` from the pushed
311/// tip, commit them as the bot, force-push, and return the checkout to
312/// where it stood.
313fn commit_request(
314    context: &Context,
315    client: &Client,
316    next: Version,
317    tag: &str,
318    branch: &str,
319    paths: &[String],
320) -> Result<Vec<String>, RkError> {
321    let target = &context.target;
322    let original = super::git(target, &["symbolic-ref", "--short", "-q", "HEAD"])?;
323    let original = if original.status.success() {
324        String::from_utf8_lossy(&original.stdout).trim().to_owned()
325    } else {
326        context.head()?
327    };
328    super::git_ok(
329        target,
330        &["checkout", "-q", "-B", branch],
331        "a fresh request branch from the pushed tip",
332    )?;
333    let committed = (|| {
334        let mut files: Vec<String> = context
335            .adapter
336            .write_version(target, next)?
337            .into_iter()
338            .map(String::from)
339            .collect();
340        cliff::changelog(target, tag, paths)?;
341        files.push("CHANGELOG.md".to_owned());
342        // SATISFIES bot-identity:the-bot-identity-reaches-one-project
343        let (name, email) = client.identity()?;
344        let mut add = vec!["add", "--"];
345        add.extend(files.iter().map(String::as_str));
346        super::git_ok(target, &add, "the release files staged")?;
347        let user = format!("user.name={name}");
348        let mail = format!("user.email={email}");
349        let message = format!("chore(release): {tag}");
350        super::git_ok(
351            target,
352            &[
353                "-c",
354                &user,
355                "-c",
356                &mail,
357                "commit",
358                "-q",
359                "--allow-empty",
360                "-m",
361                &message,
362            ],
363            "the release commit",
364        )?;
365        // SATISFIES release-engine:one-request-per-base
366        let refspec = format!("HEAD:refs/heads/{branch}");
367        super::push(context, &["--force", &refspec])?;
368        Ok::<_, RkError>(files)
369    })();
370    let restored = super::git_ok(
371        target,
372        &["checkout", "-q", "-f", &original],
373        "the checkout returned to the pushed tip",
374    );
375    let files = committed?;
376    restored?;
377    Ok(files)
378}