use std::path::{Path, PathBuf};
use camino::Utf8Path;
use serde::Serialize;
use serde_json::Value;
use crate::detect::Forge;
use crate::landing::manifest::version_is_newer;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum Answer {
Yes,
No,
Unknown,
NotApplicable,
}
impl Answer {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Yes => "yes",
Self::No => "no",
Self::Unknown => "unknown",
Self::NotApplicable => "not-applicable",
}
}
#[must_use]
pub const fn of(value: bool) -> Self {
if value { Self::Yes } else { Self::No }
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum Checks {
Pending,
Passing,
Failing,
Unknown,
}
impl Checks {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Pending => "pending",
Self::Passing => "passing",
Self::Failing => "failing",
Self::Unknown => "unknown",
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct Request {
pub state: &'static str,
pub number: Option<u64>,
pub proposed_version: Option<String>,
pub armed: Answer,
pub checks: Checks,
pub behind_trunk: Answer,
}
impl Request {
#[must_use]
pub const fn unknown() -> Self {
Self {
state: "unknown",
number: None,
proposed_version: None,
armed: Answer::Unknown,
checks: Checks::Unknown,
behind_trunk: Answer::Unknown,
}
}
#[must_use]
pub const fn none() -> Self {
Self {
state: "none",
number: None,
proposed_version: None,
armed: Answer::NotApplicable,
checks: Checks::Unknown,
behind_trunk: Answer::NotApplicable,
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct LastRelease {
pub tag: Option<String>,
pub published: Answer,
pub artifacts_verified: Answer,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Symptom {
pub code: &'static str,
pub evidence: String,
pub recovery: &'static str,
}
pub const MERGED_NOTHING_PUBLISHED: &str = "request-merged-nothing-published";
pub const PUBLISHED_NO_ARTIFACTS: &str = "published-no-artifacts";
pub const ARTIFACT_WITHOUT_ATTESTATION: &str = "artifact-without-attestation";
pub const TAG_WITHOUT_RELEASE: &str = "tag-without-release";
const RECOVERY_TAG_WITHOUT_RELEASE: &str = "The tag exists and the release does not";
pub const LOCAL_TAG_DIFFERS: &str = "local-tag-differs";
pub const LOCAL_TAG_NOT_ON_FORGE: &str = "local-tag-not-on-forge";
const RECOVERY_STALE_LOCAL_TAG: &str = "A clone holds a stale release tag";
const RECOVERY_NOTHING_PUBLISHED: &str = "The release request merged and nothing published";
const RECOVERY_ARTIFACT_BUILD: &str = "The artifact build failed";
#[derive(Debug, Clone)]
pub struct Pair {
pub driver: String,
pub forge: Forge,
pub repo: String,
pub trunk: String,
pub artifacts: bool,
pub package: Option<String>,
pub registry_answer: Option<crate::profile::RegistryAnswer>,
}
impl Pair {
#[must_use]
pub fn request_prefix(&self) -> Option<&'static str> {
match self.driver.as_str() {
"rust" => Some("release-plz-"),
"python" => Some("release-please--"),
"bash" => Some("chore/release-v"),
_ => None,
}
}
#[must_use]
pub fn publish_workflow(&self) -> Option<&'static str> {
match (self.forge, self.driver.as_str()) {
(Forge::Github, "rust") => Some("release-plz.yml"),
(Forge::Github, "python") => Some("release-please.yml"),
(Forge::Github, "bash") => Some("release.yml"),
_ => None,
}
}
#[must_use]
pub fn artifact_workflow(&self) -> Option<&'static str> {
if !self.artifacts {
return None;
}
match (self.forge, self.driver.as_str()) {
(Forge::Github, "rust" | "bash") => Some("release.yml"),
_ => None,
}
}
#[must_use]
pub fn registry(&self) -> Option<Registry> {
registry_for(&self.driver, self.registry_answer)
}
}
#[must_use]
pub fn registry_for(
driver: &str,
answer: Option<crate::profile::RegistryAnswer>,
) -> Option<Registry> {
match (driver, answer) {
("rust", Some(crate::profile::RegistryAnswer::None)) => None,
("rust", _) => Some(Registry::CratesIo),
("python", _) => Some(Registry::Pypi),
_ => None,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Registry {
CratesIo,
Pypi,
}
impl Registry {
#[must_use]
pub fn url(self, package: &str, version: Option<&str>) -> String {
match (self, version) {
(Self::CratesIo, Some(version)) => {
format!("https://crates.io/api/v1/crates/{package}/{version}")
}
(Self::CratesIo, None) => format!("https://crates.io/api/v1/crates/{package}"),
(Self::Pypi, Some(version)) => {
format!("https://pypi.org/pypi/{package}/{version}/json")
}
(Self::Pypi, None) => format!("https://pypi.org/pypi/{package}/json"),
}
}
}
#[must_use]
pub fn proposed_version(title: &str) -> Option<String> {
title
.split_whitespace()
.rev()
.map(|token| token.trim_matches(|c: char| !c.is_ascii_alphanumeric() && c != '.'))
.map(|token| token.strip_prefix('v').unwrap_or(token))
.find(|token| is_version(token))
.map(str::to_owned)
}
#[must_use]
pub fn is_version(text: &str) -> bool {
let core = text.split_once('-').map_or(text, |(core, _)| core);
let parts: Vec<&str> = core.split('.').collect();
parts.len() == 3
&& parts
.iter()
.all(|part| !part.is_empty() && part.bytes().all(|byte| byte.is_ascii_digit()))
}
#[must_use]
pub fn newest_release<'a>(tags: impl IntoIterator<Item = &'a str>) -> Option<String> {
let mut newest: Option<&str> = None;
for tag in tags {
let Some(version) = tag.strip_prefix('v') else {
continue;
};
if !is_version(version) || version.contains('-') {
continue;
}
if newest.is_none_or(|current| version_is_newer(version, ¤t[1..])) {
newest = Some(tag);
}
}
newest.map(str::to_owned)
}
#[must_use]
pub fn github_checks(body: &Value) -> Checks {
let Some(runs) = body["check_runs"].as_array() else {
return Checks::Unknown;
};
if runs.is_empty() {
return Checks::Pending;
}
let failed = runs.iter().any(|run| {
matches!(
run["conclusion"].as_str(),
Some(
"failure"
| "cancelled"
| "timed_out"
| "action_required"
| "startup_failure"
| "stale"
)
)
});
if failed {
return Checks::Failing;
}
if runs
.iter()
.any(|run| run["status"].as_str() != Some("completed"))
{
return Checks::Pending;
}
Checks::Passing
}
#[must_use]
pub fn gitlab_checks(status: Option<&str>) -> Checks {
match status {
Some("success") => Checks::Passing,
Some("failed" | "canceled" | "canceling") => Checks::Failing,
Some(_) | None => Checks::Pending,
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Run {
pub id: u64,
pub status: String,
pub conclusion: Option<String>,
pub head_branch: Option<String>,
}
impl Run {
#[must_use]
pub const fn concluded(&self) -> bool {
self.conclusion.is_some()
}
#[must_use]
pub fn succeeded(&self) -> bool {
self.conclusion.as_deref() == Some("success")
}
}
#[must_use]
pub fn github_run_on(body: &Value, workflow: &str, sha: &str) -> Option<Run> {
let suffix = format!("/{workflow}");
body["workflow_runs"]
.as_array()?
.iter()
.filter(|run| run["head_sha"].as_str() == Some(sha))
.filter(|run| run["event"].as_str() == Some("push"))
.filter(|run| {
run["path"]
.as_str()
.is_some_and(|path| path.ends_with(&suffix) || path == workflow)
})
.map(|run| Run {
id: run["id"].as_u64().unwrap_or_default(),
status: run["status"].as_str().unwrap_or("unknown").to_owned(),
conclusion: (run["status"].as_str() == Some("completed"))
.then(|| run["conclusion"].as_str().map(str::to_owned))
.flatten(),
head_branch: run["head_branch"].as_str().map(str::to_owned),
})
.min_by_key(|run| run.id)
}
#[must_use]
pub fn gitlab_pipeline_on(body: &Value, reference: impl Fn(&str) -> bool) -> Option<Run> {
body.as_array()?
.iter()
.filter(|pipeline| pipeline["ref"].as_str().is_some_and(&reference))
.map(|pipeline| {
let status = pipeline["status"].as_str().unwrap_or("unknown").to_owned();
let conclusion = matches!(
status.as_str(),
"success" | "failed" | "canceled" | "skipped"
)
.then(|| status.clone());
Run {
id: pipeline["id"].as_u64().unwrap_or_default(),
status,
conclusion,
head_branch: pipeline["ref"].as_str().map(str::to_owned),
}
})
.min_by_key(|run| run.id)
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Page {
pub exists: bool,
pub assets: Vec<String>,
pub unattested: Vec<String>,
pub attestation_unknown: bool,
}
impl Page {
#[must_use]
pub const fn verified(&self) -> Answer {
if !self.exists || self.assets.is_empty() || !self.unattested.is_empty() {
return Answer::No;
}
if self.attestation_unknown {
return Answer::Unknown;
}
Answer::Yes
}
}
#[derive(Debug, Clone)]
pub struct Merged {
pub number: u64,
pub version: String,
pub tagged: Answer,
pub published: Answer,
}
#[must_use]
pub fn symptoms(
merged: Option<&Merged>,
last: &LastRelease,
page: Option<&Page>,
artifacts: bool,
) -> Vec<Symptom> {
let mut found = Vec::new();
if let Some(merged) = merged {
let missing = match (merged.tagged, merged.published) {
(Answer::No, _) => Some(format!("no tag v{} exists", merged.version)),
(_, Answer::No) => Some(format!("{} is not published", merged.version)),
_ => None,
};
if let Some(missing) = missing {
found.push(Symptom {
code: MERGED_NOTHING_PUBLISHED,
evidence: format!(
"release request #{} proposing {} merged, and {missing}",
merged.number, merged.version
),
recovery: RECOVERY_NOTHING_PUBLISHED,
});
}
}
if let (true, Some(tag), Answer::Yes, Some(page)) =
(artifacts, last.tag.as_deref(), last.published, page)
{
if page.assets.is_empty() {
found.push(Symptom {
code: PUBLISHED_NO_ARTIFACTS,
evidence: format!("{tag} is published and its release page carries no artifact"),
recovery: RECOVERY_ARTIFACT_BUILD,
});
} else if !page.unattested.is_empty() {
found.push(Symptom {
code: ARTIFACT_WITHOUT_ATTESTATION,
evidence: format!(
"{tag} carries {} with no attestation",
page.unattested.join(", ")
),
recovery: RECOVERY_ARTIFACT_BUILD,
});
}
}
found
}
pub fn orphan_tags(
tags: &[&str],
lines: &[&str],
mut complete: impl FnMut(&str) -> Answer,
) -> Vec<Symptom> {
let line_of = |version: &str| {
lines
.iter()
.find(|line| version.starts_with(&format!("{line}.")))
.copied()
};
let mut groups: std::collections::BTreeMap<Option<&str>, Vec<&str>> =
std::collections::BTreeMap::new();
for tag in tags {
let Some(version) = tag.strip_prefix('v') else {
continue;
};
if !is_version(version) || version.contains('-') {
continue;
}
groups.entry(line_of(version)).or_default().push(tag);
}
let mut found = Vec::new();
for (line, mut group) in groups {
group.sort_by(|left, right| {
if version_is_newer(&left[1..], &right[1..]) {
std::cmp::Ordering::Less
} else if version_is_newer(&right[1..], &left[1..]) {
std::cmp::Ordering::Greater
} else {
std::cmp::Ordering::Equal
}
});
let place = line.map_or_else(|| "the trunk".to_owned(), |line| format!("the line {line}"));
for tag in group {
if complete(tag) != Answer::No {
break;
}
found.push(Symptom {
code: TAG_WITHOUT_RELEASE,
evidence: format!(
"{tag} exists on {place} with no complete release page, and no complete release of that line is newer"
),
recovery: RECOVERY_TAG_WITHOUT_RELEASE,
});
}
}
found
}
#[must_use]
pub fn local_tag_drift(local: &[(&str, &str)], forge: &[(&str, &str)]) -> Vec<Symptom> {
let mut local: Vec<(&str, &str)> = local
.iter()
.filter(|(tag, _)| tag.strip_prefix('v').is_some_and(is_version))
.copied()
.collect();
local.sort_unstable();
local
.into_iter()
.filter_map(|(tag, commit)| {
match forge.iter().find(|(name, _)| *name == tag) {
Some((_, held)) if *held == commit => None,
Some((_, held)) => Some(Symptom {
code: LOCAL_TAG_DIFFERS,
evidence: format!(
"the clone holds {tag} at {commit} and the forge holds it at {held}; the forge holds the true release tags, so refresh the clone: git fetch origin '+refs/tags/{tag}:refs/tags/{tag}'"
),
recovery: RECOVERY_STALE_LOCAL_TAG,
}),
None => Some(Symptom {
code: LOCAL_TAG_NOT_ON_FORGE,
evidence: format!(
"the clone holds {tag} at {commit} and the forge holds no such tag; the forge holds the true release tags, so refresh the clone: git fetch --prune origin '+refs/tags/v*:refs/tags/v*'"
),
recovery: RECOVERY_STALE_LOCAL_TAG,
}),
}
})
.collect()
}
#[derive(Debug)]
pub enum Api {
Ok(Value),
Missing,
Failed(String),
}
#[derive(Debug, Clone)]
pub struct ForgeCli {
cli: PathBuf,
cwd: PathBuf,
}
impl ForgeCli {
#[must_use]
pub const fn new(cli: PathBuf, cwd: PathBuf) -> Self {
Self { cli, cwd }
}
pub fn resolve(forge: Forge, target: &Utf8Path) -> Result<Self, crate::error::RkError> {
Ok(Self {
cli: crate::setup::context::resolve_cli(forge)?,
cwd: target.as_std_path().to_path_buf(),
})
}
#[must_use]
pub fn api(&self, path: &str) -> Api {
let answered = std::process::Command::new(&self.cli)
.args(["api", path])
.current_dir(&self.cwd)
.env("GH_PAGER", "")
.env("GLAB_PAGER", "")
.env("NO_COLOR", "1")
.output();
let output = match answered {
Ok(output) => output,
Err(source) => return Api::Failed(format!("the forge CLI did not run: {source}")),
};
if output.status.success() {
return serde_json::from_slice::<Value>(&output.stdout).map_or_else(
|_| Api::Failed("the forge answer did not parse as JSON".to_owned()),
Api::Ok,
);
}
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("HTTP 404") || stderr.contains("404 Not Found") {
return Api::Missing;
}
Api::Failed(
stderr
.lines()
.rev()
.find(|line| !line.trim().is_empty())
.unwrap_or("no output")
.to_owned(),
)
}
}
#[must_use]
pub fn registry_serves(registry: Registry, package: &str, version: Option<&str>) -> Answer {
let curl = std::env::var_os("RK_CURL_BIN").unwrap_or_else(|| "curl".into());
let answered = std::process::Command::new(curl)
.args([
"-sS",
"-o",
"/dev/null",
"-w",
"%{http_code}",
"--max-time",
"20",
])
.args(["-A", concat!("release-kit/", env!("CARGO_PKG_VERSION"))])
.arg(registry.url(package, version))
.output();
match answered {
Ok(output) => match String::from_utf8_lossy(&output.stdout).trim() {
"200" => Answer::Yes,
"404" => Answer::No,
_ => Answer::Unknown,
},
Err(_) => Answer::Unknown,
}
}
#[must_use]
pub fn package_name(target: &Path, driver: &str) -> Option<String> {
let (file, table) = match driver {
"rust" => ("Cargo.toml", "package"),
"python" => ("pyproject.toml", "project"),
_ => return None,
};
let text = std::fs::read_to_string(target.join(file)).ok()?;
let value: toml::Table = toml::from_str(&text).ok()?;
value.get(table)?.get("name")?.as_str().map(str::to_owned)
}
#[must_use]
pub fn gitlab_project(repo: &str) -> String {
repo.replace('/', "%2F")
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::{
Answer, Checks, LastRelease, Merged, Page, github_checks, github_run_on, gitlab_checks,
local_tag_drift, newest_release, proposed_version, symptoms,
};
#[test]
fn a_title_proposes_its_last_version_token() {
assert_eq!(
proposed_version("chore: release v0.8.7").as_deref(),
Some("0.8.7")
);
assert_eq!(
proposed_version("chore(main): release 1.2.3").as_deref(),
Some("1.2.3")
);
assert_eq!(
proposed_version("chore(release): v2.0.0-rc.1").as_deref(),
Some("2.0.0-rc.1")
);
assert_eq!(proposed_version("chore: release"), None);
}
#[test]
fn the_newest_release_skips_candidates_and_other_tags() {
assert_eq!(
newest_release(["v0.9.0", "v0.10.0", "v0.11.0-rc.1", "latest", "v1.2"]).as_deref(),
Some("v0.10.0")
);
assert_eq!(newest_release(["nightly"]), None);
}
#[test]
fn check_runs_judge_failing_before_pending() {
assert_eq!(github_checks(&json!({"check_runs": []})), Checks::Pending);
assert_eq!(
github_checks(&json!({"check_runs": [
{"status": "completed", "conclusion": "success"},
{"status": "in_progress", "conclusion": null}
]})),
Checks::Pending
);
assert_eq!(
github_checks(&json!({"check_runs": [
{"status": "completed", "conclusion": "failure"},
{"status": "in_progress", "conclusion": null}
]})),
Checks::Failing
);
assert_eq!(
github_checks(&json!({"check_runs": [
{"status": "completed", "conclusion": "success"},
{"status": "completed", "conclusion": "skipped"}
]})),
Checks::Passing
);
assert_eq!(github_checks(&json!({})), Checks::Unknown);
assert_eq!(gitlab_checks(Some("running")), Checks::Pending);
assert_eq!(gitlab_checks(Some("failed")), Checks::Failing);
assert_eq!(gitlab_checks(Some("success")), Checks::Passing);
}
#[test]
fn a_run_is_selected_by_its_commit_never_by_recency() {
let body = json!({"workflow_runs": [
{"id": 30, "head_sha": "bbb", "event": "pull_request", "status": "in_progress",
"conclusion": null, "path": ".github/workflows/release-plz.yml", "head_branch": "feat/x"},
{"id": 20, "head_sha": "aaa", "event": "push", "status": "completed",
"conclusion": "success", "path": ".github/workflows/release-plz.yml", "head_branch": "master"},
{"id": 21, "head_sha": "aaa", "event": "push", "status": "queued",
"conclusion": null, "path": ".github/workflows/release.yml", "head_branch": "v1.0.0"}
]});
let publish = github_run_on(&body, "release-plz.yml", "aaa").expect("the publish run");
assert_eq!(publish.id, 20);
assert!(publish.succeeded());
let artifact = github_run_on(&body, "release.yml", "aaa").expect("the artifact run");
assert_eq!(artifact.id, 21);
assert!(!artifact.concluded());
assert_eq!(github_run_on(&body, "release-plz.yml", "bbb"), None);
}
fn last(tag: &str, published: Answer) -> LastRelease {
LastRelease {
tag: Some(tag.to_owned()),
published,
artifacts_verified: Answer::Unknown,
}
}
#[test]
fn each_symptom_has_its_rule() {
let merged = Merged {
number: 12,
version: "1.2.3".into(),
tagged: Answer::No,
published: Answer::Unknown,
};
let found = symptoms(Some(&merged), &last("v1.2.2", Answer::Yes), None, false);
assert_eq!(found.len(), 1);
assert_eq!(found[0].code, super::MERGED_NOTHING_PUBLISHED);
assert_eq!(
found[0].recovery,
"The release request merged and nothing published"
);
let unpublished = Merged {
tagged: Answer::Yes,
published: Answer::No,
..merged.clone()
};
assert_eq!(
symptoms(Some(&unpublished), &last("v1.2.3", Answer::No), None, false)[0].code,
super::MERGED_NOTHING_PUBLISHED
);
let shipped = Merged {
tagged: Answer::Yes,
published: Answer::Yes,
..merged
};
let empty = Page {
exists: true,
..Page::default()
};
let found = symptoms(
Some(&shipped),
&last("v1.2.3", Answer::Yes),
Some(&empty),
true,
);
assert_eq!(found.len(), 1);
assert_eq!(found[0].code, super::PUBLISHED_NO_ARTIFACTS);
assert_eq!(found[0].recovery, "The artifact build failed");
let unattested = Page {
exists: true,
assets: vec!["widget.tar.gz".into()],
unattested: vec!["widget.tar.gz".into()],
attestation_unknown: false,
};
let found = symptoms(None, &last("v1.2.3", Answer::Yes), Some(&unattested), true);
assert_eq!(found[0].code, super::ARTIFACT_WITHOUT_ATTESTATION);
assert!(found[0].evidence.contains("widget.tar.gz"));
assert!(
symptoms(None, &last("v1.2.3", Answer::Yes), Some(&empty), false).is_empty(),
"a pair that builds no artifact has no artifact symptom"
);
}
#[test]
fn local_tag_drift_reports_a_moved_and_a_local_only_tag() {
let found = local_tag_drift(
&[("v0.3.0", "ccc"), ("v0.1.0", "aaa"), ("v0.2.0", "bbb")],
&[("v0.1.0", "fff"), ("v0.3.0", "ccc"), ("v0.4.0", "ddd")],
);
let codes: Vec<&str> = found.iter().map(|symptom| symptom.code).collect();
assert_eq!(
codes,
[super::LOCAL_TAG_DIFFERS, super::LOCAL_TAG_NOT_ON_FORGE],
"the moved tag and the local-only tag, in tag order: {found:?}"
);
assert!(found[0].evidence.contains("v0.1.0 at aaa"));
assert!(found[0].evidence.contains("holds it at fff"));
assert!(found[1].evidence.contains("v0.2.0 at bbb"));
assert!(
found
.iter()
.all(|symptom| symptom.recovery == "A clone holds a stale release tag")
);
}
#[test]
fn the_answer_words_are_the_wire_form() {
for (answer, word) in [
(Answer::Yes, "yes"),
(Answer::No, "no"),
(Answer::Unknown, "unknown"),
(Answer::NotApplicable, "not-applicable"),
] {
assert_eq!(answer.as_str(), word);
assert_eq!(
serde_json::to_string(&answer).expect("serializes"),
format!("\"{word}\"")
);
}
assert_eq!(Answer::of(true), Answer::Yes);
}
}