blob: d516694e0e7b355b3aae2a0658a9005f0ae569ae [file]
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
use super::package::Package;
use crate::{find_command, workspace_dir};
use anyhow::{Context, bail};
use semver::{Op, Version, VersionReq};
use std::fmt::{self, Display, Formatter};
use std::str::FromStr;
use toml_edit::{DocumentMut, Item};
pub fn validate_release_versions(packages: &[Package]) -> anyhow::Result<()> {
let baseline = latest_final_release_tag()?;
let mut violations = Vec::new();
for package in packages {
if package.public_compat_dependencies().is_empty() {
continue;
}
if let Some(violation) = validate_package(package, &baseline)? {
violations.push(violation);
}
}
if !violations.is_empty() {
let details = violations
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n");
bail!("release version validation failed against {baseline}:\n{details}");
}
println!("release version targets are compatible with changes since {baseline}");
Ok(())
}
fn validate_package(package: &Package, baseline: &str) -> anyhow::Result<Option<Violation>> {
let baseline_manifest = manifest_at(baseline, package)?;
let current_manifest = CargoManifest::read(package.path().join("Cargo.toml"))?;
let baseline_package_version = baseline_manifest.package_version()?;
let mut changes = Vec::new();
for dependency in package.public_compat_dependencies() {
let previous = baseline_manifest.dependency_version(dependency)?;
// Managed dependency constraints have not been written to this manifest yet.
let target = match package.release_dependency_version(dependency) {
Some(version) => version.clone(),
None => current_manifest.dependency_version(dependency)?,
};
if !same_cargo_compatibility_line(&previous, &target) {
changes.push(DependencyChange {
name: dependency.to_string(),
previous,
target,
});
}
}
Ok(validate_target_version(
package.name(),
baseline_package_version,
package.version().clone(),
changes,
))
}
fn latest_final_release_tag() -> anyhow::Result<String> {
let mut cmd = find_command("git", workspace_dir());
cmd.args([
"describe",
"--tags",
"--abbrev=0",
"--match",
"v[0-9]*",
"--exclude",
"*-*",
"HEAD",
]);
let output = cmd
.output()
.context("failed to find the latest final release tag")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!(
"failed to find the latest final release tag; fetch the repository tags before running update-version: {}",
stderr.trim()
);
}
let tag = String::from_utf8(output.stdout)
.context("latest final release tag is not valid UTF-8")?
.trim()
.to_string();
let version = tag.strip_prefix('v').unwrap_or(&tag);
let version = Version::parse(version)
.with_context(|| format!("release tag {tag} is not a semantic version"))?;
if !version.pre.is_empty() {
bail!("release tag {tag} is not a final release");
}
Ok(tag)
}
fn manifest_at(reference: &str, package: &Package) -> anyhow::Result<CargoManifest> {
let manifest = format!("{reference}:{}/Cargo.toml", package.name());
let mut cmd = find_command("git", workspace_dir());
cmd.args(["show", &manifest]);
let output = cmd
.output()
.with_context(|| format!("failed to read {manifest}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("failed to read {manifest}: {}", stderr.trim());
}
let content = String::from_utf8(output.stdout)
.with_context(|| format!("{manifest} is not valid UTF-8"))?;
CargoManifest::parse(&content, &manifest)
}
struct CargoManifest {
document: DocumentMut,
source: String,
}
impl CargoManifest {
fn read(path: impl AsRef<std::path::Path>) -> anyhow::Result<Self> {
let path = path.as_ref();
let content = std::fs::read_to_string(path)
.with_context(|| format!("failed to read {}", path.display()))?;
Self::parse(&content, &path.display().to_string())
}
fn parse(content: &str, source: &str) -> anyhow::Result<Self> {
let document =
DocumentMut::from_str(content).with_context(|| format!("failed to parse {source}"))?;
Ok(Self {
document,
source: source.to_string(),
})
}
fn package_version(&self) -> anyhow::Result<Version> {
let version = self.document["package"]["version"]
.as_str()
.with_context(|| format!("missing package.version in {}", self.source))?;
Version::parse(version)
.with_context(|| format!("invalid package.version in {}: {version}", self.source))
}
fn dependency_version(&self, name: &str) -> anyhow::Result<Version> {
let dependencies = self
.document
.get("dependencies")
.and_then(Item::as_table_like)
.with_context(|| format!("missing dependencies in {}", self.source))?;
let dependency = dependencies.get(name).with_context(|| {
format!(
"missing public compatibility dependency {name} in {}",
self.source
)
})?;
let requirement = dependency.as_str().or_else(|| {
dependency
.as_table_like()
.and_then(|dependency| dependency.get("version"))
.and_then(Item::as_str)
});
let requirement = requirement
.with_context(|| format!("missing version for dependency {name} in {}", self.source))?;
dependency_requirement_version(requirement).with_context(|| {
format!(
"unsupported version requirement for dependency {name} in {}: {requirement}",
self.source
)
})
}
}
fn dependency_requirement_version(requirement: &str) -> anyhow::Result<Version> {
let requirement = VersionReq::parse(requirement)?;
let [comparator] = requirement.comparators.as_slice() else {
bail!("expected one version comparator");
};
if !matches!(comparator.op, Op::Caret | Op::Exact | Op::Tilde) {
bail!("expected a caret, exact, or tilde version comparator");
}
let mut version = Version::new(
comparator.major,
comparator.minor.unwrap_or(0),
comparator.patch.unwrap_or(0),
);
version.pre = comparator.pre.clone();
Ok(version)
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct DependencyChange {
name: String,
previous: Version,
target: Version,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct Violation {
package: String,
baseline: Version,
target: Version,
required: Version,
changes: Vec<DependencyChange>,
}
impl Display for Violation {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
writeln!(
f,
" {} must use an incompatible version bump: configured {}, expected at least {} from baseline {}",
self.package, self.target, self.required, self.baseline
)?;
for change in &self.changes {
writeln!(
f,
" - {} changed from {} to {}",
change.name, change.previous, change.target
)?;
}
Ok(())
}
}
fn validate_target_version(
package: &str,
baseline: Version,
target: Version,
changes: Vec<DependencyChange>,
) -> Option<Violation> {
if changes.is_empty() {
return None;
}
let required = next_incompatible_version(&baseline);
if target >= required {
return None;
}
Some(Violation {
package: package.to_string(),
baseline,
target,
required,
changes,
})
}
fn same_cargo_compatibility_line(previous: &Version, target: &Version) -> bool {
if !previous.pre.is_empty() || !target.pre.is_empty() {
return previous == target;
}
if previous.major != target.major {
return false;
}
if previous.major != 0 {
return true;
}
if previous.minor != target.minor {
return false;
}
if previous.minor != 0 {
return true;
}
previous.patch == target.patch
}
fn next_incompatible_version(version: &Version) -> Version {
if version.major != 0 {
Version::new(version.major + 1, 0, 0)
} else if version.minor != 0 {
Version::new(0, version.minor + 1, 0)
} else {
Version::new(0, 0, version.patch + 1)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn version(value: &str) -> Version {
Version::parse(value).unwrap()
}
fn change(name: &str, previous: &str, target: &str) -> DependencyChange {
DependencyChange {
name: name.to_string(),
previous: version(previous),
target: version(target),
}
}
#[test]
fn object_store_incompatible_upgrade_requires_integration_bump() {
let violation = validate_target_version(
"integrations/object_store",
version("0.58.0"),
version("0.58.1"),
vec![change("object_store", "0.13.1", "0.14.1")],
)
.unwrap();
assert_eq!(violation.required, version("0.59.0"));
}
#[test]
fn parquet_incompatible_upgrade_accepts_integration_bump() {
let violation = validate_target_version(
"integrations/parquet",
version("0.8.2"),
version("0.9.0"),
vec![change("parquet", "58.0.0", "59.0.0")],
);
assert!(violation.is_none());
}
#[test]
fn opendal_incompatible_upgrade_requires_parquet_bump() {
let violation = validate_target_version(
"integrations/parquet",
version("0.9.1"),
version("0.9.2"),
vec![change("opendal", "0.58.2", "0.59.0")],
)
.unwrap();
assert_eq!(violation.required, version("0.10.0"));
}
#[test]
fn compatible_dependency_upgrade_does_not_require_incompatible_bump() {
assert!(same_cargo_compatibility_line(
&version("0.58.1"),
&version("0.58.2")
));
assert!(same_cargo_compatibility_line(
&version("1.2.0"),
&version("1.9.0")
));
assert!(!same_cargo_compatibility_line(
&version("0.0.1"),
&version("0.0.2")
));
assert_eq!(
next_incompatible_version(&version("0.58.1")),
version("0.59.0")
);
assert_eq!(
next_incompatible_version(&version("0.0.1")),
version("0.0.2")
);
assert_eq!(
next_incompatible_version(&version("1.2.3")),
version("2.0.0")
);
}
#[test]
fn parses_cargo_dependency_requirements() {
assert_eq!(
dependency_requirement_version("0.14.1").unwrap(),
version("0.14.1")
);
assert_eq!(
dependency_requirement_version("59.0").unwrap(),
version("59.0.0")
);
}
#[test]
fn reads_inline_dependency_versions() {
let manifest = CargoManifest::parse(
r#"
[package]
name = "parquet_opendal"
version = "0.9.0"
[dependencies]
opendal = { version = "0.58.1", path = "../../core" }
parquet = { version = "59.0", default-features = false }
"#,
"Cargo.toml",
)
.unwrap();
assert_eq!(manifest.package_version().unwrap(), version("0.9.0"));
assert_eq!(
manifest.dependency_version("opendal").unwrap(),
version("0.58.1")
);
assert_eq!(
manifest.dependency_version("parquet").unwrap(),
version("59.0.0")
);
}
}