use semver::Version;
use serde::{Deserialize, Serialize};
pub const RELEASE_VERIFY_SCHEMA_VERSION: u32 = 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ReleaseChannel {
GithubRelease,
Homebrew,
Scoop,
CratesIo,
InstallerScript,
}
impl ReleaseChannel {
pub fn as_str(self) -> &'static str {
match self {
ReleaseChannel::GithubRelease => "github_release",
ReleaseChannel::Homebrew => "homebrew",
ReleaseChannel::Scoop => "scoop",
ReleaseChannel::CratesIo => "crates_io",
ReleaseChannel::InstallerScript => "installer_script",
}
}
pub fn is_dispatch_driven(self) -> bool {
matches!(self, ReleaseChannel::Homebrew | ReleaseChannel::Scoop)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ChannelState {
UpToDate,
Stale,
Missing,
ChecksumMismatch,
InstallerFailed,
NetworkUnavailable,
NotConfigured,
}
impl ChannelState {
pub fn as_str(self) -> &'static str {
match self {
ChannelState::UpToDate => "up_to_date",
ChannelState::Stale => "stale",
ChannelState::Missing => "missing",
ChannelState::ChecksumMismatch => "checksum_mismatch",
ChannelState::InstallerFailed => "installer_failed",
ChannelState::NetworkUnavailable => "network_unavailable",
ChannelState::NotConfigured => "not_configured",
}
}
pub fn is_ready(self) -> bool {
matches!(self, ChannelState::UpToDate | ChannelState::NotConfigured)
}
}
#[derive(Debug, Clone, Default)]
pub struct ChannelObservation {
pub configured: bool,
pub reachable: bool,
pub observed_version: Option<String>,
pub checksum_ok: Option<bool>,
pub dispatch_ran: Option<bool>,
pub installer_ok: Option<bool>,
}
impl ChannelObservation {
pub fn healthy(version: &str) -> Self {
Self {
configured: true,
reachable: true,
observed_version: Some(version.to_string()),
checksum_ok: Some(true),
dispatch_ran: Some(true),
installer_ok: Some(true),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChannelReport {
pub channel: ReleaseChannel,
pub state: ChannelState,
pub expected_version: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub observed_version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub checksum_ok: Option<bool>,
pub detail: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub manual_next_action: Option<String>,
}
fn parse_release_version(raw: &str) -> Option<Version> {
let trimmed = raw.trim().trim_start_matches('v').trim();
let core = trimmed.split(['-', '+']).next().unwrap_or(trimmed).trim();
Version::parse(core).ok()
}
pub fn evaluate_channel(
channel: ReleaseChannel,
expected_version: &str,
obs: &ChannelObservation,
) -> ChannelReport {
let mut report = ChannelReport {
channel,
state: ChannelState::UpToDate,
expected_version: expected_version.to_string(),
observed_version: obs.observed_version.clone(),
checksum_ok: obs.checksum_ok,
detail: String::new(),
manual_next_action: None,
};
if !obs.configured {
report.state = ChannelState::NotConfigured;
report.detail = format!("{} is not configured for this release", channel.as_str());
return report;
}
if !obs.reachable {
report.state = ChannelState::NetworkUnavailable;
report.detail = format!(
"{} could not be checked: network unavailable",
channel.as_str()
);
report.manual_next_action =
Some("re-run release verification with network access".to_string());
return report;
}
if obs.checksum_ok == Some(false) {
report.state = ChannelState::ChecksumMismatch;
report.detail = format!(
"{} asset checksum did not match the expected digest for {expected_version}",
channel.as_str()
);
report.manual_next_action = Some(
"re-upload the release asset and regenerate checksums; do not advertise this release until it matches".to_string(),
);
return report;
}
if channel == ReleaseChannel::InstallerScript && obs.installer_ok == Some(false) {
report.state = ChannelState::InstallerFailed;
report.detail =
"installer script ran but did not produce a working expected-version binary"
.to_string();
report.manual_next_action =
Some("fix the installer script's asset URL/version resolution and re-test".to_string());
return report;
}
if channel.is_dispatch_driven() && obs.dispatch_ran == Some(false) {
report.state = ChannelState::Missing;
report.detail = format!(
"{} notify workflow_dispatch did not run for {expected_version}",
channel.as_str()
);
report.manual_next_action = Some(format!(
"manually dispatch the {} update workflow for version {expected_version}",
channel.as_str()
));
return report;
}
match obs.observed_version.as_deref() {
None => {
report.state = ChannelState::Missing;
report.detail = format!(
"{} did not publish a discoverable version for {expected_version}",
channel.as_str()
);
report.manual_next_action = Some(format!(
"publish/refresh {} for version {expected_version}",
channel.as_str()
));
}
Some(observed) => match (
parse_release_version(expected_version),
parse_release_version(observed),
) {
(Some(want), Some(have)) if have < want => {
report.state = ChannelState::Stale;
report.detail = format!(
"{} serves {observed}, behind expected {expected_version}",
channel.as_str()
);
report.manual_next_action = if channel.is_dispatch_driven() {
Some(format!(
"manually dispatch the {} update workflow for version {expected_version}",
channel.as_str()
))
} else {
Some(format!(
"re-publish {} for version {expected_version}",
channel.as_str()
))
};
}
(Some(_), Some(_)) => {
report.state = ChannelState::UpToDate;
report.detail = format!("{} serves {observed}", channel.as_str());
}
_ => {
report.state = ChannelState::Stale;
report.detail = format!(
"{} version {observed} could not be compared to expected {expected_version}",
channel.as_str()
);
report.manual_next_action = Some(format!(
"manually confirm {} is at version {expected_version}",
channel.as_str()
));
}
},
}
report
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct ReleaseVerifySummary {
pub total: usize,
pub ready: usize,
pub stale: usize,
pub missing: usize,
pub checksum_mismatch: usize,
pub installer_failed: usize,
pub network_unavailable: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReleaseVerificationReport {
pub schema_version: u32,
pub expected_version: String,
pub channels: Vec<ChannelReport>,
pub summary: ReleaseVerifySummary,
pub overall_ready: bool,
}
impl ReleaseVerificationReport {
pub fn build(
expected_version: &str,
observations: &[(ReleaseChannel, ChannelObservation)],
) -> Self {
let channels: Vec<ChannelReport> = observations
.iter()
.map(|(channel, obs)| evaluate_channel(*channel, expected_version, obs))
.collect();
let mut summary = ReleaseVerifySummary {
total: channels.len(),
..Default::default()
};
for report in &channels {
match report.state {
ChannelState::UpToDate | ChannelState::NotConfigured => summary.ready += 1,
ChannelState::Stale => summary.stale += 1,
ChannelState::Missing => summary.missing += 1,
ChannelState::ChecksumMismatch => summary.checksum_mismatch += 1,
ChannelState::InstallerFailed => summary.installer_failed += 1,
ChannelState::NetworkUnavailable => summary.network_unavailable += 1,
}
}
let overall_ready = channels.iter().all(|c| c.state.is_ready());
Self {
schema_version: RELEASE_VERIFY_SCHEMA_VERSION,
expected_version: expected_version.to_string(),
channels,
summary,
overall_ready,
}
}
pub fn manual_actions(&self) -> Vec<&ChannelReport> {
self.channels
.iter()
.filter(|c| c.manual_next_action.is_some())
.collect()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChannelObservationInput {
pub channel: ReleaseChannel,
#[serde(default)]
pub configured: bool,
#[serde(default)]
pub reachable: bool,
#[serde(default)]
pub observed_version: Option<String>,
#[serde(default)]
pub checksum_ok: Option<bool>,
#[serde(default)]
pub dispatch_ran: Option<bool>,
#[serde(default)]
pub installer_ok: Option<bool>,
}
impl ChannelObservationInput {
fn into_pair(self) -> (ReleaseChannel, ChannelObservation) {
(
self.channel,
ChannelObservation {
configured: self.configured,
reachable: self.reachable,
observed_version: self.observed_version,
checksum_ok: self.checksum_ok,
dispatch_ran: self.dispatch_ran,
installer_ok: self.installer_ok,
},
)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReleaseVerifyRequest {
pub expected_version: String,
pub channels: Vec<ChannelObservationInput>,
}
pub fn verify_request(request: ReleaseVerifyRequest) -> ReleaseVerificationReport {
let observations: Vec<(ReleaseChannel, ChannelObservation)> = request
.channels
.into_iter()
.map(ChannelObservationInput::into_pair)
.collect();
ReleaseVerificationReport::build(&request.expected_version, &observations)
}
pub fn verify_from_json(input: &str) -> Result<ReleaseVerificationReport, serde_json::Error> {
let request: ReleaseVerifyRequest = serde_json::from_str(input)?;
Ok(verify_request(request))
}
#[cfg(test)]
mod tests {
use super::*;
const V: &str = "0.6.13";
fn all_channels(
make: impl Fn(ReleaseChannel) -> ChannelObservation,
) -> Vec<(ReleaseChannel, ChannelObservation)> {
[
ReleaseChannel::GithubRelease,
ReleaseChannel::Homebrew,
ReleaseChannel::Scoop,
ReleaseChannel::CratesIo,
ReleaseChannel::InstallerScript,
]
.into_iter()
.map(|c| (c, make(c)))
.collect()
}
#[test]
fn complete_release_is_overall_ready() {
let obs = all_channels(|_| ChannelObservation::healthy(V));
let report = ReleaseVerificationReport::build(V, &obs);
assert!(
report.overall_ready,
"all-healthy release should be ready: {report:?}"
);
assert_eq!(report.summary.total, 5);
assert_eq!(report.summary.ready, 5);
assert!(report.manual_actions().is_empty());
}
#[test]
fn missing_homebrew_dispatch_is_flagged_with_manual_action() {
let obs = all_channels(|c| {
let mut o = ChannelObservation::healthy(V);
if c == ReleaseChannel::Homebrew {
o.dispatch_ran = Some(false);
o.observed_version = Some("0.6.12".to_string());
}
o
});
let report = ReleaseVerificationReport::build(V, &obs);
assert!(!report.overall_ready);
assert_eq!(report.summary.missing, 1);
let brew = report
.channels
.iter()
.find(|c| c.channel == ReleaseChannel::Homebrew)
.unwrap();
assert_eq!(brew.state, ChannelState::Missing);
assert!(
brew.manual_next_action
.as_deref()
.unwrap()
.contains("dispatch")
);
}
#[test]
fn missing_scoop_dispatch_is_flagged() {
let obs = all_channels(|c| {
let mut o = ChannelObservation::healthy(V);
if c == ReleaseChannel::Scoop {
o.dispatch_ran = Some(false);
o.observed_version = None;
}
o
});
let report = ReleaseVerificationReport::build(V, &obs);
let scoop = report
.channels
.iter()
.find(|c| c.channel == ReleaseChannel::Scoop)
.unwrap();
assert_eq!(scoop.state, ChannelState::Missing);
assert!(!report.overall_ready);
}
#[test]
fn checksum_mismatch_blocks_release() {
let obs = all_channels(|c| {
let mut o = ChannelObservation::healthy(V);
if c == ReleaseChannel::GithubRelease {
o.checksum_ok = Some(false);
}
o
});
let report = ReleaseVerificationReport::build(V, &obs);
let gh = report
.channels
.iter()
.find(|c| c.channel == ReleaseChannel::GithubRelease)
.unwrap();
assert_eq!(gh.state, ChannelState::ChecksumMismatch);
assert_eq!(gh.checksum_ok, Some(false));
assert_eq!(report.summary.checksum_mismatch, 1);
assert!(!report.overall_ready);
}
#[test]
fn stale_binary_version_is_detected() {
let obs = all_channels(|c| {
let mut o = ChannelObservation::healthy(V);
if c == ReleaseChannel::CratesIo {
o.observed_version = Some("0.6.11".to_string());
}
o
});
let report = ReleaseVerificationReport::build(V, &obs);
let crates = report
.channels
.iter()
.find(|c| c.channel == ReleaseChannel::CratesIo)
.unwrap();
assert_eq!(crates.state, ChannelState::Stale);
assert!(crates.detail.contains("0.6.11"));
assert_eq!(report.summary.stale, 1);
}
#[test]
fn installer_script_failure_is_distinct() {
let obs = all_channels(|c| {
let mut o = ChannelObservation::healthy(V);
if c == ReleaseChannel::InstallerScript {
o.installer_ok = Some(false);
}
o
});
let report = ReleaseVerificationReport::build(V, &obs);
let inst = report
.channels
.iter()
.find(|c| c.channel == ReleaseChannel::InstallerScript)
.unwrap();
assert_eq!(inst.state, ChannelState::InstallerFailed);
assert_eq!(report.summary.installer_failed, 1);
assert!(!report.overall_ready);
}
#[test]
fn network_unavailable_is_reported_not_assumed_ready() {
let obs = all_channels(|c| {
let mut o = ChannelObservation::healthy(V);
if c == ReleaseChannel::Homebrew {
o.reachable = false;
}
o
});
let report = ReleaseVerificationReport::build(V, &obs);
let brew = report
.channels
.iter()
.find(|c| c.channel == ReleaseChannel::Homebrew)
.unwrap();
assert_eq!(brew.state, ChannelState::NetworkUnavailable);
assert_eq!(report.summary.network_unavailable, 1);
assert!(!report.overall_ready);
assert!(brew.manual_next_action.is_some());
}
#[test]
fn ahead_or_not_configured_channels_are_ready() {
let obs = vec![
(
ReleaseChannel::GithubRelease,
ChannelObservation::healthy("0.6.14"),
),
(
ReleaseChannel::CratesIo,
ChannelObservation {
configured: false,
..Default::default()
},
),
];
let report = ReleaseVerificationReport::build(V, &obs);
assert!(
report.overall_ready,
"ahead + not-configured are both ready: {report:?}"
);
assert_eq!(report.summary.ready, 2);
assert_eq!(report.channels[1].state, ChannelState::NotConfigured);
}
#[test]
fn json_contract_is_stable_and_round_trips() {
let obs = all_channels(|_| ChannelObservation::healthy(V));
let report = ReleaseVerificationReport::build(V, &obs);
let value = serde_json::to_value(&report).expect("serialize");
assert_eq!(value["schema_version"], RELEASE_VERIFY_SCHEMA_VERSION);
assert_eq!(value["expected_version"], V);
assert_eq!(value["overall_ready"], true);
assert_eq!(value["channels"][0]["channel"], "github_release");
assert_eq!(value["channels"][0]["state"], "up_to_date");
let back: ReleaseVerificationReport = serde_json::from_value(value).expect("deserialize");
assert_eq!(back, report);
}
#[test]
fn verify_from_json_drives_a_complete_release_fixture() {
let input = r#"{
"expected_version": "0.6.13",
"channels": [
{"channel":"github_release","configured":true,"reachable":true,"observed_version":"0.6.13","checksum_ok":true},
{"channel":"homebrew","configured":true,"reachable":true,"observed_version":"0.6.13","dispatch_ran":true},
{"channel":"crates_io","configured":true,"reachable":true,"observed_version":"0.6.13"}
]
}"#;
let report = verify_from_json(input).expect("parse request");
assert!(
report.overall_ready,
"complete-release fixture should be ready: {report:?}"
);
assert_eq!(report.summary.total, 3);
assert_eq!(report.expected_version, "0.6.13");
}
#[test]
fn verify_from_json_flags_lagging_dispatch_channel() {
let input = r#"{
"expected_version": "0.6.13",
"channels": [
{"channel":"github_release","configured":true,"reachable":true,"observed_version":"0.6.13","checksum_ok":true},
{"channel":"scoop","configured":true,"reachable":true,"observed_version":"0.6.12","dispatch_ran":false}
]
}"#;
let report = verify_from_json(input).expect("parse request");
assert!(!report.overall_ready);
let scoop = report
.channels
.iter()
.find(|c| c.channel == ReleaseChannel::Scoop)
.unwrap();
assert_eq!(scoop.state, ChannelState::Missing);
assert!(scoop.manual_next_action.is_some());
}
#[test]
fn verify_request_round_trips_through_its_json_contract() {
let request = ReleaseVerifyRequest {
expected_version: "0.6.13".to_string(),
channels: vec![ChannelObservationInput {
channel: ReleaseChannel::GithubRelease,
configured: true,
reachable: true,
observed_version: Some("0.6.13".to_string()),
checksum_ok: Some(true),
dispatch_ran: None,
installer_ok: None,
}],
};
let json = serde_json::to_string(&request).unwrap();
let back: ReleaseVerifyRequest = serde_json::from_str(&json).unwrap();
assert_eq!(back, request);
}
#[test]
fn version_parser_tolerates_v_prefix_and_suffix() {
assert_eq!(
parse_release_version("v0.6.13"),
Version::parse("0.6.13").ok()
);
assert_eq!(
parse_release_version("0.6.13-dirty"),
Version::parse("0.6.13").ok()
);
assert!(parse_release_version("not-a-version").is_none());
}
}