Skip to main content

release_kit/
release_engine.rs

1//! The release engine: `rk release request`, `rk release build`, and
2//! `rk release publish`, for each driver that has an adapter, on GitHub
3//! and GitLab.
4//!
5//! The engine reads every fact it acts on from the landing record: the
6//! driver, the forge, the repository, the trunk, the style, the
7//! integration, the line prefix, and the registry answer. It takes no flag
8//! that restates one. The judgments are pure and live in [`guard`]; the
9//! actions read, judge, and write through the driver [`adapter`], the
10//! pinned [`cliff`], and the forge CLIs in [`forge`]. Each action returns
11//! its report, and the handler in `src/commands/release.rs` renders it.
12
13pub mod adapter;
14pub mod build;
15pub mod cliff;
16pub mod forge;
17pub mod guard;
18pub mod publish;
19pub mod request;
20
21use camino::{Utf8Path, Utf8PathBuf};
22use serde::Serialize;
23
24use crate::detect::Forge;
25use crate::diagnostic::{Diagnostic, Reason};
26use crate::error::RkError;
27use crate::landing::manifest::{self, Integration, MergeForm, Style};
28use crate::profile::{BuildAnswer, ReleaseMode};
29use crate::release_state::{self, Registry};
30
31use self::adapter::DriverAdapter;
32
33/// The branch prefix of the engine's one release request per base: the
34/// request for `<base>` lives on `rk-release/<base>`.
35pub const REQUEST_PREFIX: &str = "rk-release/";
36
37/// The directory the build writes its report, its notes, and its
38/// checksums into, beside `dist/` and never inside it, because a registry
39/// upload sends every file `dist/` holds.
40pub const OUT_DIR: &str = ".rk-release";
41
42/// The build report inside [`OUT_DIR`], which `rk release publish` reads.
43pub const BUILD_REPORT: &str = "build.json";
44
45/// The release notes inside [`OUT_DIR`].
46pub const NOTES: &str = "notes.md";
47
48/// The checksum file inside [`OUT_DIR`], one `<sha256>  <name>` per
49/// artifact.
50pub const CHECKSUMS: &str = "SHA256SUMS";
51
52/// Whether `branch` is a release line `<prefix><major>.<minor>` under
53/// `prefix`.
54#[must_use]
55pub fn is_line(prefix: &str, branch: &str) -> bool {
56    branch
57        .strip_prefix(prefix)
58        .and_then(|line| line.split_once('.'))
59        .is_some_and(|(major, minor)| {
60            <[&str; 2]>::from((major, minor))
61                .iter()
62                .all(|part| !part.is_empty() && part.bytes().all(|byte| byte.is_ascii_digit()))
63        })
64}
65
66/// The recovery section for a tag at another commit, or one nobody could
67/// read.
68pub const RECOVERY_TAG: &str = "The tag exists and the release does not";
69
70/// The recovery section for bytes that differ from what already shipped.
71pub const RECOVERY_DEFECTIVE: &str = "A published version is defective";
72
73/// What one action returns: its report, the human lines, and the next
74/// steps, rendered by the handler.
75#[derive(Debug)]
76pub struct Done<R: Serialize> {
77    /// The machine report.
78    pub report: R,
79    /// The human report, one line each.
80    pub lines: Vec<String>,
81    /// The next steps the human report names.
82    pub next: Vec<String>,
83}
84
85/// Everything the record answers for one engine run.
86pub struct Context {
87    /// The checkout the action runs in.
88    pub target: Utf8PathBuf,
89    /// The forge.
90    pub forge: Forge,
91    /// The project path on the forge.
92    pub repo: String,
93    /// The trunk branch.
94    pub trunk: String,
95    /// The release style.
96    pub style: Option<Style>,
97    /// The integration authority.
98    pub integration: Integration,
99    /// The merge form an armed request merges with.
100    pub merge_form: MergeForm,
101    /// The release-line branch prefix.
102    pub line_prefix: String,
103    /// The registry the release publishes to, where it has one.
104    pub registry: Option<Registry>,
105    /// The package name the registry knows, where the driver names one.
106    pub package: Option<String>,
107    /// The driver adapter.
108    pub adapter: Box<dyn DriverAdapter>,
109}
110
111impl Context {
112    /// Read the record at `target`.
113    ///
114    /// # Errors
115    ///
116    /// A refusal where the target carries no landing, or a landing the
117    /// engine does not release.
118    pub fn load(target: &Utf8Path) -> Result<Self, RkError> {
119        let refuse = |message: String, expected: &str| {
120            RkError::refusal(
121                Diagnostic::new(Reason::StateDrift, message)
122                    .expected(expected.to_owned())
123                    .target_state("unchanged"),
124            )
125        };
126        if !target.is_dir() {
127            return Err(RkError::missing(
128                Diagnostic::new(
129                    Reason::TargetNotFound,
130                    format!("target {target} is not a directory"),
131                )
132                .expected("the checkout the release job runs in"),
133            ));
134        }
135        let record = manifest::load(target)?.ok_or_else(|| {
136            refuse(
137                format!("no landing at {target}"),
138                "a target release-kit landed",
139            )
140        })?;
141        let release = &record.profile.release;
142        if release.mode != ReleaseMode::Automatic {
143            return Err(refuse(
144                "the record selects no automated release".to_owned(),
145                "profile.release.mode = \"automatic\"",
146            ));
147        }
148        let driver = release.driver.clone().unwrap_or_default();
149        let command = release
150            .build
151            .as_ref()
152            .and_then(|build| build.command.clone())
153            .unwrap_or_else(|| BuildAnswer::COMMAND.to_owned());
154        let adapter = adapter::for_driver(&driver, &command).ok_or_else(|| {
155            refuse(
156                format!("the release driver {driver:?} does not release through the rk engine"),
157                "a bash or python release driver; a rust release runs release-plz",
158            )
159        })?;
160        let forge = record
161            .profile
162            .forge
163            .as_deref()
164            .and_then(Forge::parse)
165            .ok_or_else(|| {
166                refuse(
167                    "the record names no forge this binary releases on".to_owned(),
168                    "a github or gitlab forge",
169                )
170            })?;
171        if record.parameters.repo.is_empty() {
172            return Err(refuse(
173                "the record names no forge repository".to_owned(),
174                "the project path recorded at landing",
175            ));
176        }
177        Ok(Self {
178            package: adapter::package_name(target.as_std_path(), &driver),
179            registry: release_state::registry_for(&driver, release.registry),
180            target: target.to_owned(),
181            forge,
182            repo: record.parameters.repo.clone(),
183            trunk: record.git.trunk.clone(),
184            style: release.style,
185            integration: record.git.integration,
186            merge_form: record.git.merge_form,
187            line_prefix: release
188                .line_prefix
189                .clone()
190                .unwrap_or_else(|| crate::config::LINE_PREFIX_DEFAULT.to_owned()),
191            adapter,
192        })
193    }
194
195    /// The driver's name.
196    #[must_use]
197    pub fn driver(&self) -> &'static str {
198        self.adapter.driver()
199    }
200
201    /// The branch the push landed on: the checked-out branch, or on a
202    /// detached CI checkout the branch the forge's job names.
203    #[must_use]
204    pub fn pushed_branch(&self) -> Option<String> {
205        if let Ok(output) = git(&self.target, &["symbolic-ref", "--short", "-q", "HEAD"])
206            && output.status.success()
207        {
208            let branch = String::from_utf8_lossy(&output.stdout).trim().to_owned();
209            if !branch.is_empty() {
210                return Some(branch);
211            }
212        }
213        let from = |var: &str| std::env::var(var).ok().filter(|value| !value.is_empty());
214        from("CI_COMMIT_BRANCH").or_else(|| {
215            (std::env::var("GITHUB_REF_TYPE").as_deref() == Ok("branch"))
216                .then(|| from("GITHUB_REF_NAME"))
217                .flatten()
218        })
219    }
220
221    /// Whether `branch` is a release base: the trunk, or a release line
222    /// `<prefix><major>.<minor>`.
223    #[must_use]
224    pub fn is_base(&self, branch: &str) -> bool {
225        branch == self.trunk || self.is_line(branch)
226    }
227
228    /// Whether `branch` is a release line.
229    #[must_use]
230    pub fn is_line(&self, branch: &str) -> bool {
231        is_line(&self.line_prefix, branch)
232    }
233
234    /// The commit the checkout stands at.
235    ///
236    /// # Errors
237    ///
238    /// A refusal where git cannot resolve `HEAD`.
239    pub fn head(&self) -> Result<String, RkError> {
240        git_line(&self.target, &["rev-parse", "HEAD"], "a commit checked out")
241    }
242}
243
244/// The request branch for a base.
245#[must_use]
246pub fn request_branch(base: &str) -> String {
247    format!("{REQUEST_PREFIX}{base}")
248}
249
250/// The variables through which git takes a commit identity over its
251/// configuration. A job or a hook may export them, and the release commit
252/// must carry the identity the forge names for the bot's token instead.
253const IDENTITY_VARS: [&str; 4] = [
254    "GIT_AUTHOR_NAME",
255    "GIT_AUTHOR_EMAIL",
256    "GIT_COMMITTER_NAME",
257    "GIT_COMMITTER_EMAIL",
258];
259
260/// Run one git command against `root`, with the hook and identity
261/// variables scrubbed.
262///
263/// A run under a hook then acts on `root` alone, and a commit takes the
264/// identity its command configures.
265///
266/// # Errors
267///
268/// A refusal where git does not run.
269pub fn git(root: &Utf8Path, args: &[&str]) -> Result<std::process::Output, RkError> {
270    let mut command = std::process::Command::new(crate::probes::git_bin());
271    for var in crate::maintenance::GIT_HOOK_VARS
272        .iter()
273        .chain(&IDENTITY_VARS)
274    {
275        command.env_remove(var);
276    }
277    command
278        .arg("-C")
279        .arg(root.as_std_path())
280        .env("GIT_TERMINAL_PROMPT", "0")
281        .args(args)
282        .output()
283        .map_err(|source| {
284            RkError::subprocess(
285                Diagnostic::new(
286                    Reason::SubprocessSpawn,
287                    format!("git did not run: {source}"),
288                )
289                .expected("git installed and on PATH"),
290            )
291        })
292}
293
294/// Run one git command that must succeed, with its failure typed.
295///
296/// # Errors
297///
298/// The command's failure, with git's last line.
299pub fn git_ok(root: &Utf8Path, args: &[&str], expected: &str) -> Result<String, RkError> {
300    let output = git(root, args)?;
301    if output.status.success() {
302        return Ok(String::from_utf8_lossy(&output.stdout).into_owned());
303    }
304    Err(RkError::subprocess(
305        Diagnostic::new(Reason::SubprocessFailed, last_line(&output.stderr))
306            .expected(expected.to_owned()),
307    ))
308}
309
310/// The trimmed output of one git command that must succeed.
311///
312/// # Errors
313///
314/// The command's failure.
315pub fn git_line(root: &Utf8Path, args: &[&str], expected: &str) -> Result<String, RkError> {
316    git_ok(root, args, expected).map(|text| text.trim().to_owned())
317}
318
319/// The last non-empty line of a tool's output.
320#[must_use]
321pub fn last_line(bytes: &[u8]) -> String {
322    String::from_utf8_lossy(bytes)
323        .lines()
324        .rev()
325        .find(|line| !line.trim().is_empty())
326        .unwrap_or("no detail")
327        .trim()
328        .to_owned()
329}
330
331/// The SHA-256 of a file, as lowercase hex.
332///
333/// # Errors
334///
335/// The read's failure.
336pub fn file_sha256(path: &Utf8Path) -> Result<String, RkError> {
337    Ok(crate::digest::Digest::of(&std::fs::read(path).map_err(RkError::Io)?).to_string())
338}
339
340/// A conflict with what the forge or the registry already holds, named
341/// with its recovery section.
342#[must_use]
343pub fn conflict(message: String, recovery: &str) -> RkError {
344    RkError::refusal(
345        Diagnostic::new(Reason::ReleaseConflict, message)
346            .expected("a release whose tag and bytes agree with this commit's build")
347            .action(format!("rk method recovery: {recovery}"))
348            .target_state("no further forge write ran"),
349    )
350}
351
352/// The file name of a path.
353#[must_use]
354pub fn file_name(path: &Utf8Path) -> String {
355    path.file_name().unwrap_or(path.as_str()).to_owned()
356}
357
358/// An embedded forge text with its tokens replaced.
359///
360/// # Errors
361///
362/// A block this binary does not embed, a defect in the binary.
363pub fn forge_text(block: &str, tokens: &[(&str, &str)]) -> Result<String, RkError> {
364    let mut text = crate::projection::embedded_block(block)?
365        .trim_end_matches('\n')
366        .to_owned();
367    for (token, value) in tokens {
368        text = text.replace(token, value);
369    }
370    Ok(text)
371}
372
373/// The default CI token's variable, where the credential the forge CLI
374/// would push with is that token alone.
375///
376/// A push made with the default token starts no pipeline, so the release
377/// half would never run. `GH_TOKEN` and `GITLAB_TOKEN` carry the bot
378/// credential; a job that sets only `GITHUB_TOKEN` or `CI_JOB_TOKEN`, or
379/// sets the bot variable to the same value, pushes as the default token.
380#[must_use]
381pub fn default_ci_token(
382    forge: Forge,
383    var: impl Fn(&str) -> Option<String>,
384) -> Option<&'static str> {
385    let (bot, default) = match forge {
386        Forge::Github => ("GH_TOKEN", "GITHUB_TOKEN"),
387        Forge::Gitlab => ("GITLAB_TOKEN", "CI_JOB_TOKEN"),
388    };
389    let set = |name: &str| var(name).filter(|value| !value.is_empty());
390    let default_value = set(default)?;
391    match set(bot) {
392        Some(value) if value != default_value => None,
393        _ => Some(default),
394    }
395}
396
397/// Push to `origin` with the bot credential the job sets.
398///
399/// SATISFIES release-engine:the-request-pushes-as-the-bot
400///
401/// An https remote is pushed without any credential its URL embeds, such
402/// as a CI job token, through a credential helper that reads the bot
403/// token's variable at push time, so rk never holds the token.
404///
405/// # Errors
406///
407/// The push's failure, with git's last line.
408pub fn push(context: &Context, args: &[&str]) -> Result<(), RkError> {
409    let url = git_line(
410        &context.target,
411        &["remote", "get-url", "origin"],
412        "an origin remote",
413    )?;
414    let (user, var) = match context.forge {
415        Forge::Github => ("x-access-token", "GH_TOKEN"),
416        Forge::Gitlab => ("oauth2", "GITLAB_TOKEN"),
417    };
418    let mut command: Vec<String> = Vec::new();
419    let mut remote = url.clone();
420    if let Some(rest) = url.strip_prefix("https://")
421        && std::env::var(var).is_ok_and(|value| !value.is_empty())
422    {
423        let host_path = rest
424            .split_once('/')
425            .map_or(rest, |(authority, _)| authority);
426        let bare = host_path
427            .rsplit_once('@')
428            .map_or(host_path, |(_, host)| host);
429        remote = format!(
430            "https://{bare}{}",
431            rest.find('/').map_or("", |at| &rest[at..])
432        );
433        command.extend([
434            "-c".to_owned(),
435            "credential.helper=".to_owned(),
436            "-c".to_owned(),
437            format!(
438                "credential.helper=!f() {{ echo username={user}; echo \"password=${var}\"; }}; f"
439            ),
440        ]);
441    }
442    command.push("push".to_owned());
443    command.push(remote);
444    command.extend(args.iter().map(|arg| (*arg).to_owned()));
445    let borrowed: Vec<&str> = command.iter().map(String::as_str).collect();
446    git_ok(
447        &context.target,
448        &borrowed,
449        "a push the remote accepts under the bot credential",
450    )
451    .map(drop)
452}
453
454#[cfg(test)]
455mod tests {
456    use super::build::{Artifact, BuildReport};
457    use super::publish::PublishReport;
458    use super::request::RequestReport;
459
460    /// SATISFIES release-engine:the-output-declares-its-schema
461    ///
462    /// The three reports, held by snapshot: a renamed or dropped field
463    /// fails here and becomes a schema-version bump.
464    #[test]
465    fn each_engine_report_holds_its_schema() {
466        let request = RequestReport {
467            schema: super::request::SCHEMA,
468            state: "opened",
469            reason: None,
470            base: Some("master".into()),
471            version: Some("0.1.0".into()),
472            proposal: Some("0.1.1".into()),
473            tag: Some("v0.1.1".into()),
474            request: Some(7),
475            armed: Some(true),
476            files: vec!["VERSION".into(), "CHANGELOG.md".into()],
477        };
478        assert_eq!(
479            serde_json::to_string(&request).expect("serializes"),
480            r#"{"schema":"rk.release-request/1","state":"opened","base":"master","version":"0.1.0","proposal":"0.1.1","tag":"v0.1.1","request":7,"armed":true,"files":["VERSION","CHANGELOG.md"]}"#
481        );
482        let build = BuildReport {
483            schema: super::build::SCHEMA.into(),
484            state: "release".into(),
485            reason: None,
486            version: "1.4.3rc2".into(),
487            tag: "v1.4.3-rc.2".into(),
488            candidate: true,
489            commit: "aaa".into(),
490            artifacts: vec![Artifact {
491                path: "dist/acme-1.4.3rc2.tar.gz".into(),
492                sha256: "00".into(),
493            }],
494            checksums: Some(".rk-release/SHA256SUMS".into()),
495            notes: Some(".rk-release/notes.md".into()),
496        };
497        assert_eq!(
498            serde_json::to_string(&build).expect("serializes"),
499            r#"{"schema":"rk.release-build/1","state":"release","version":"1.4.3rc2","tag":"v1.4.3-rc.2","candidate":true,"commit":"aaa","artifacts":[{"path":"dist/acme-1.4.3rc2.tar.gz","sha256":"00"}],"checksums":".rk-release/SHA256SUMS","notes":".rk-release/notes.md"}"#
500        );
501        let publish = PublishReport {
502            schema: super::publish::SCHEMA,
503            state: "resumed",
504            reason: None,
505            tag: "v0.1.0".into(),
506            candidate: false,
507            uploaded: vec!["widget-0.1.0.tar.gz.sha256".into()],
508            replaced: Vec::new(),
509            kept: vec!["widget-0.1.0.tar.gz".into()],
510            linked: Vec::new(),
511        };
512        assert_eq!(
513            serde_json::to_string(&publish).expect("serializes"),
514            r#"{"schema":"rk.release-publish/1","state":"resumed","tag":"v0.1.0","candidate":false,"uploaded":["widget-0.1.0.tar.gz.sha256"],"replaced":[],"kept":["widget-0.1.0.tar.gz"],"linked":[]}"#
515        );
516    }
517
518    #[test]
519    fn only_the_default_token_alone_is_the_default_token() {
520        use crate::detect::Forge;
521        let vars = |pairs: &'static [(&'static str, &'static str)]| {
522            move |name: &str| {
523                pairs
524                    .iter()
525                    .find(|(key, _)| *key == name)
526                    .map(|(_, value)| (*value).to_owned())
527            }
528        };
529        assert_eq!(
530            super::default_ci_token(Forge::Github, vars(&[("GITHUB_TOKEN", "j")])),
531            Some("GITHUB_TOKEN")
532        );
533        assert_eq!(
534            super::default_ci_token(
535                Forge::Github,
536                vars(&[("GITHUB_TOKEN", "j"), ("GH_TOKEN", "j")])
537            ),
538            Some("GITHUB_TOKEN")
539        );
540        assert_eq!(
541            super::default_ci_token(
542                Forge::Github,
543                vars(&[("GITHUB_TOKEN", "j"), ("GH_TOKEN", "b")])
544            ),
545            None
546        );
547        assert_eq!(
548            super::default_ci_token(Forge::Gitlab, vars(&[("CI_JOB_TOKEN", "j")])),
549            Some("CI_JOB_TOKEN")
550        );
551        assert_eq!(
552            super::default_ci_token(
553                Forge::Gitlab,
554                vars(&[("CI_JOB_TOKEN", "j"), ("GITLAB_TOKEN", "b")])
555            ),
556            None
557        );
558        assert_eq!(super::default_ci_token(Forge::Gitlab, vars(&[])), None);
559    }
560}