pub mod adapter;
pub mod build;
pub mod cliff;
pub mod forge;
pub mod guard;
pub mod publish;
pub mod request;
use camino::{Utf8Path, Utf8PathBuf};
use serde::Serialize;
use crate::detect::Forge;
use crate::diagnostic::{Diagnostic, Reason};
use crate::error::RkError;
use crate::landing::manifest::{self, Integration, MergeForm, Style};
use crate::profile::{BuildAnswer, ReleaseMode};
use crate::release_state::{self, Registry};
use self::adapter::DriverAdapter;
pub const REQUEST_PREFIX: &str = "rk-release/";
pub const OUT_DIR: &str = ".rk-release";
pub const BUILD_REPORT: &str = "build.json";
pub const NOTES: &str = "notes.md";
pub const CHECKSUMS: &str = "SHA256SUMS";
#[must_use]
pub fn is_line(prefix: &str, branch: &str) -> bool {
branch
.strip_prefix(prefix)
.and_then(|line| line.split_once('.'))
.is_some_and(|(major, minor)| {
<[&str; 2]>::from((major, minor))
.iter()
.all(|part| !part.is_empty() && part.bytes().all(|byte| byte.is_ascii_digit()))
})
}
pub const RECOVERY_TAG: &str = "The tag exists and the release does not";
pub const RECOVERY_DEFECTIVE: &str = "A published version is defective";
#[derive(Debug)]
pub struct Done<R: Serialize> {
pub report: R,
pub lines: Vec<String>,
pub next: Vec<String>,
}
pub struct Context {
pub target: Utf8PathBuf,
pub forge: Forge,
pub repo: String,
pub trunk: String,
pub style: Option<Style>,
pub integration: Integration,
pub merge_form: MergeForm,
pub line_prefix: String,
pub registry: Option<Registry>,
pub package: Option<String>,
pub adapter: Box<dyn DriverAdapter>,
}
impl Context {
pub fn load(target: &Utf8Path) -> Result<Self, RkError> {
let refuse = |message: String, expected: &str| {
RkError::refusal(
Diagnostic::new(Reason::StateDrift, message)
.expected(expected.to_owned())
.target_state("unchanged"),
)
};
if !target.is_dir() {
return Err(RkError::missing(
Diagnostic::new(
Reason::TargetNotFound,
format!("target {target} is not a directory"),
)
.expected("the checkout the release job runs in"),
));
}
let record = manifest::load(target)?.ok_or_else(|| {
refuse(
format!("no landing at {target}"),
"a target release-kit landed",
)
})?;
let release = &record.profile.release;
if release.mode != ReleaseMode::Automatic {
return Err(refuse(
"the record selects no automated release".to_owned(),
"profile.release.mode = \"automatic\"",
));
}
let driver = release.driver.clone().unwrap_or_default();
let command = release
.build
.as_ref()
.and_then(|build| build.command.clone())
.unwrap_or_else(|| BuildAnswer::COMMAND.to_owned());
let adapter = adapter::for_driver(&driver, &command).ok_or_else(|| {
refuse(
format!("the release driver {driver:?} does not release through the rk engine"),
"a bash or python release driver; a rust release runs release-plz",
)
})?;
let forge = record
.profile
.forge
.as_deref()
.and_then(Forge::parse)
.ok_or_else(|| {
refuse(
"the record names no forge this binary releases on".to_owned(),
"a github or gitlab forge",
)
})?;
if record.parameters.repo.is_empty() {
return Err(refuse(
"the record names no forge repository".to_owned(),
"the project path recorded at landing",
));
}
Ok(Self {
package: adapter::package_name(target.as_std_path(), &driver),
registry: release_state::registry_for(&driver, release.registry),
target: target.to_owned(),
forge,
repo: record.parameters.repo.clone(),
trunk: record.git.trunk.clone(),
style: release.style,
integration: record.git.integration,
merge_form: record.git.merge_form,
line_prefix: release
.line_prefix
.clone()
.unwrap_or_else(|| crate::config::LINE_PREFIX_DEFAULT.to_owned()),
adapter,
})
}
#[must_use]
pub fn driver(&self) -> &'static str {
self.adapter.driver()
}
#[must_use]
pub fn pushed_branch(&self) -> Option<String> {
if let Ok(output) = git(&self.target, &["symbolic-ref", "--short", "-q", "HEAD"])
&& output.status.success()
{
let branch = String::from_utf8_lossy(&output.stdout).trim().to_owned();
if !branch.is_empty() {
return Some(branch);
}
}
let from = |var: &str| std::env::var(var).ok().filter(|value| !value.is_empty());
from("CI_COMMIT_BRANCH").or_else(|| {
(std::env::var("GITHUB_REF_TYPE").as_deref() == Ok("branch"))
.then(|| from("GITHUB_REF_NAME"))
.flatten()
})
}
#[must_use]
pub fn is_base(&self, branch: &str) -> bool {
branch == self.trunk || self.is_line(branch)
}
#[must_use]
pub fn is_line(&self, branch: &str) -> bool {
is_line(&self.line_prefix, branch)
}
pub fn head(&self) -> Result<String, RkError> {
git_line(&self.target, &["rev-parse", "HEAD"], "a commit checked out")
}
}
#[must_use]
pub fn request_branch(base: &str) -> String {
format!("{REQUEST_PREFIX}{base}")
}
const IDENTITY_VARS: [&str; 4] = [
"GIT_AUTHOR_NAME",
"GIT_AUTHOR_EMAIL",
"GIT_COMMITTER_NAME",
"GIT_COMMITTER_EMAIL",
];
pub fn git(root: &Utf8Path, args: &[&str]) -> Result<std::process::Output, RkError> {
let mut command = std::process::Command::new(crate::probes::git_bin());
for var in crate::maintenance::GIT_HOOK_VARS
.iter()
.chain(&IDENTITY_VARS)
{
command.env_remove(var);
}
command
.arg("-C")
.arg(root.as_std_path())
.env("GIT_TERMINAL_PROMPT", "0")
.args(args)
.output()
.map_err(|source| {
RkError::subprocess(
Diagnostic::new(
Reason::SubprocessSpawn,
format!("git did not run: {source}"),
)
.expected("git installed and on PATH"),
)
})
}
pub fn git_ok(root: &Utf8Path, args: &[&str], expected: &str) -> Result<String, RkError> {
let output = git(root, args)?;
if output.status.success() {
return Ok(String::from_utf8_lossy(&output.stdout).into_owned());
}
Err(RkError::subprocess(
Diagnostic::new(Reason::SubprocessFailed, last_line(&output.stderr))
.expected(expected.to_owned()),
))
}
pub fn git_line(root: &Utf8Path, args: &[&str], expected: &str) -> Result<String, RkError> {
git_ok(root, args, expected).map(|text| text.trim().to_owned())
}
#[must_use]
pub fn last_line(bytes: &[u8]) -> String {
String::from_utf8_lossy(bytes)
.lines()
.rev()
.find(|line| !line.trim().is_empty())
.unwrap_or("no detail")
.trim()
.to_owned()
}
pub fn file_sha256(path: &Utf8Path) -> Result<String, RkError> {
Ok(crate::digest::Digest::of(&std::fs::read(path).map_err(RkError::Io)?).to_string())
}
#[must_use]
pub fn conflict(message: String, recovery: &str) -> RkError {
RkError::refusal(
Diagnostic::new(Reason::ReleaseConflict, message)
.expected("a release whose tag and bytes agree with this commit's build")
.action(format!("rk method recovery: {recovery}"))
.target_state("no further forge write ran"),
)
}
#[must_use]
pub fn file_name(path: &Utf8Path) -> String {
path.file_name().unwrap_or(path.as_str()).to_owned()
}
pub fn forge_text(block: &str, tokens: &[(&str, &str)]) -> Result<String, RkError> {
let mut text = crate::projection::embedded_block(block)?
.trim_end_matches('\n')
.to_owned();
for (token, value) in tokens {
text = text.replace(token, value);
}
Ok(text)
}
#[must_use]
pub fn default_ci_token(
forge: Forge,
var: impl Fn(&str) -> Option<String>,
) -> Option<&'static str> {
let (bot, default) = match forge {
Forge::Github => ("GH_TOKEN", "GITHUB_TOKEN"),
Forge::Gitlab => ("GITLAB_TOKEN", "CI_JOB_TOKEN"),
};
let set = |name: &str| var(name).filter(|value| !value.is_empty());
let default_value = set(default)?;
match set(bot) {
Some(value) if value != default_value => None,
_ => Some(default),
}
}
pub fn push(context: &Context, args: &[&str]) -> Result<(), RkError> {
let url = git_line(
&context.target,
&["remote", "get-url", "origin"],
"an origin remote",
)?;
let (user, var) = match context.forge {
Forge::Github => ("x-access-token", "GH_TOKEN"),
Forge::Gitlab => ("oauth2", "GITLAB_TOKEN"),
};
let mut command: Vec<String> = Vec::new();
let mut remote = url.clone();
if let Some(rest) = url.strip_prefix("https://")
&& std::env::var(var).is_ok_and(|value| !value.is_empty())
{
let host_path = rest
.split_once('/')
.map_or(rest, |(authority, _)| authority);
let bare = host_path
.rsplit_once('@')
.map_or(host_path, |(_, host)| host);
remote = format!(
"https://{bare}{}",
rest.find('/').map_or("", |at| &rest[at..])
);
command.extend([
"-c".to_owned(),
"credential.helper=".to_owned(),
"-c".to_owned(),
format!(
"credential.helper=!f() {{ echo username={user}; echo \"password=${var}\"; }}; f"
),
]);
}
command.push("push".to_owned());
command.push(remote);
command.extend(args.iter().map(|arg| (*arg).to_owned()));
let borrowed: Vec<&str> = command.iter().map(String::as_str).collect();
git_ok(
&context.target,
&borrowed,
"a push the remote accepts under the bot credential",
)
.map(drop)
}
#[cfg(test)]
mod tests {
use super::build::{Artifact, BuildReport};
use super::publish::PublishReport;
use super::request::RequestReport;
#[test]
fn each_engine_report_holds_its_schema() {
let request = RequestReport {
schema: super::request::SCHEMA,
state: "opened",
reason: None,
base: Some("master".into()),
version: Some("0.1.0".into()),
proposal: Some("0.1.1".into()),
tag: Some("v0.1.1".into()),
request: Some(7),
armed: Some(true),
files: vec!["VERSION".into(), "CHANGELOG.md".into()],
};
assert_eq!(
serde_json::to_string(&request).expect("serializes"),
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"]}"#
);
let build = BuildReport {
schema: super::build::SCHEMA.into(),
state: "release".into(),
reason: None,
version: "1.4.3rc2".into(),
tag: "v1.4.3-rc.2".into(),
candidate: true,
commit: "aaa".into(),
artifacts: vec![Artifact {
path: "dist/acme-1.4.3rc2.tar.gz".into(),
sha256: "00".into(),
}],
checksums: Some(".rk-release/SHA256SUMS".into()),
notes: Some(".rk-release/notes.md".into()),
};
assert_eq!(
serde_json::to_string(&build).expect("serializes"),
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"}"#
);
let publish = PublishReport {
schema: super::publish::SCHEMA,
state: "resumed",
reason: None,
tag: "v0.1.0".into(),
candidate: false,
uploaded: vec!["widget-0.1.0.tar.gz.sha256".into()],
replaced: Vec::new(),
kept: vec!["widget-0.1.0.tar.gz".into()],
linked: Vec::new(),
};
assert_eq!(
serde_json::to_string(&publish).expect("serializes"),
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":[]}"#
);
}
#[test]
fn only_the_default_token_alone_is_the_default_token() {
use crate::detect::Forge;
let vars = |pairs: &'static [(&'static str, &'static str)]| {
move |name: &str| {
pairs
.iter()
.find(|(key, _)| *key == name)
.map(|(_, value)| (*value).to_owned())
}
};
assert_eq!(
super::default_ci_token(Forge::Github, vars(&[("GITHUB_TOKEN", "j")])),
Some("GITHUB_TOKEN")
);
assert_eq!(
super::default_ci_token(
Forge::Github,
vars(&[("GITHUB_TOKEN", "j"), ("GH_TOKEN", "j")])
),
Some("GITHUB_TOKEN")
);
assert_eq!(
super::default_ci_token(
Forge::Github,
vars(&[("GITHUB_TOKEN", "j"), ("GH_TOKEN", "b")])
),
None
);
assert_eq!(
super::default_ci_token(Forge::Gitlab, vars(&[("CI_JOB_TOKEN", "j")])),
Some("CI_JOB_TOKEN")
);
assert_eq!(
super::default_ci_token(
Forge::Gitlab,
vars(&[("CI_JOB_TOKEN", "j"), ("GITLAB_TOKEN", "b")])
),
None
);
assert_eq!(super::default_ci_token(Forge::Gitlab, vars(&[])), None);
}
}