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