1pub 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
33pub const REQUEST_PREFIX: &str = "rk-release/";
36
37pub const OUT_DIR: &str = ".rk-release";
41
42pub const BUILD_REPORT: &str = "build.json";
44
45pub const NOTES: &str = "notes.md";
47
48pub const CHECKSUMS: &str = "SHA256SUMS";
51
52#[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
66pub const RECOVERY_TAG: &str = "The tag exists and the release does not";
69
70pub const RECOVERY_DEFECTIVE: &str = "A published version is defective";
72
73#[derive(Debug)]
76pub struct Done<R: Serialize> {
77 pub report: R,
79 pub lines: Vec<String>,
81 pub next: Vec<String>,
83}
84
85pub struct Context {
87 pub target: Utf8PathBuf,
89 pub forge: Forge,
91 pub repo: String,
93 pub trunk: String,
95 pub style: Option<Style>,
97 pub integration: Integration,
99 pub merge_form: MergeForm,
101 pub line_prefix: String,
103 pub registry: Option<Registry>,
105 pub package: Option<String>,
107 pub adapter: Box<dyn DriverAdapter>,
109}
110
111impl Context {
112 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 #[must_use]
197 pub fn driver(&self) -> &'static str {
198 self.adapter.driver()
199 }
200
201 #[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 #[must_use]
224 pub fn is_base(&self, branch: &str) -> bool {
225 branch == self.trunk || self.is_line(branch)
226 }
227
228 #[must_use]
230 pub fn is_line(&self, branch: &str) -> bool {
231 is_line(&self.line_prefix, branch)
232 }
233
234 pub fn head(&self) -> Result<String, RkError> {
240 git_line(&self.target, &["rev-parse", "HEAD"], "a commit checked out")
241 }
242}
243
244#[must_use]
246pub fn request_branch(base: &str) -> String {
247 format!("{REQUEST_PREFIX}{base}")
248}
249
250const IDENTITY_VARS: [&str; 4] = [
254 "GIT_AUTHOR_NAME",
255 "GIT_AUTHOR_EMAIL",
256 "GIT_COMMITTER_NAME",
257 "GIT_COMMITTER_EMAIL",
258];
259
260pub 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
294pub 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
310pub 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#[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
331pub 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#[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#[must_use]
354pub fn file_name(path: &Utf8Path) -> String {
355 path.file_name().unwrap_or(path.as_str()).to_owned()
356}
357
358pub 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#[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
397pub 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 #[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}