1pub 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
36pub const REQUEST_PREFIX: &str = "rk-release/";
39
40pub const OUT_DIR: &str = ".rk-release";
44
45pub const BUILD_REPORT: &str = "build.json";
47
48pub const NOTES: &str = "notes.md";
50
51pub const CHECKSUMS: &str = "SHA256SUMS";
54
55#[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
69pub const RECOVERY_TAG: &str = "The tag exists and the release does not";
72
73pub const RECOVERY_DEFECTIVE: &str = "A published version is defective";
75
76#[derive(Debug)]
79pub struct Done<R: Serialize> {
80 pub report: R,
82 pub lines: Vec<String>,
84 pub next: Vec<String>,
86}
87
88pub struct Context {
90 pub target: Utf8PathBuf,
92 pub forge: Forge,
94 pub repo: String,
96 pub trunk: String,
98 pub style: Option<Style>,
100 pub integration: Integration,
102 pub merge_form: MergeForm,
104 pub line_prefix: String,
106 pub registry: Option<Registry>,
108 pub package: Option<String>,
110 pub adapter: Box<dyn DriverAdapter>,
112}
113
114impl Context {
115 #[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 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 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 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 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 #[must_use]
253 pub fn driver(&self) -> &'static str {
254 self.adapter.driver()
255 }
256
257 #[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 #[must_use]
280 pub fn is_base(&self, branch: &str) -> bool {
281 branch == self.trunk || self.is_line(branch)
282 }
283
284 #[must_use]
286 pub fn is_line(&self, branch: &str) -> bool {
287 is_line(&self.line_prefix, branch)
288 }
289
290 pub fn head(&self) -> Result<String, RkError> {
296 git_line(&self.target, &["rev-parse", "HEAD"], "a commit checked out")
297 }
298}
299
300#[must_use]
302pub fn request_branch(base: &str) -> String {
303 format!("{REQUEST_PREFIX}{base}")
304}
305
306const IDENTITY_VARS: [&str; 4] = [
310 "GIT_AUTHOR_NAME",
311 "GIT_AUTHOR_EMAIL",
312 "GIT_COMMITTER_NAME",
313 "GIT_COMMITTER_EMAIL",
314];
315
316pub 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
350pub 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
366pub 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#[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
387pub 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#[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#[must_use]
410pub fn file_name(path: &Utf8Path) -> String {
411 path.file_name().unwrap_or(path.as_str()).to_owned()
412}
413
414pub 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#[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
453pub 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 #[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}