You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@opendal.apache.org by xu...@apache.org on 2026/08/25 09:15:05 UTC
(opendal) branch xuanwo/validate-integration-version-bumps created (now fc98a3a80)
This is an automated email from the ASF dual-hosted git repository.
Xuanwo pushed a change to branch xuanwo/validate-integration-version-bumps
in repository https://gitbox.apache.org/repos/asf/opendal.git
at fc98a3a80 Merge remote-tracking branch 'origin/main' into xuanwo/validate-integration-version-bumps
This branch includes the following new commits:
new 8f09e3599 fix(dev): validate integration version bumps
new fc98a3a80 Merge remote-tracking branch 'origin/main' into xuanwo/validate-integration-version-bumps
The 2 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
(opendal) 01/02: fix(dev): validate integration version bumps
Posted by xu...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
Xuanwo pushed a commit to branch xuanwo/validate-integration-version-bumps
in repository https://gitbox.apache.org/repos/asf/opendal.git
commit 8f09e359987da2641c2c2724281a6c2327099d2f
Author: Xuanwo <gi...@xuanwo.io>
AuthorDate: Tue Aug 25 17:13:40 2026 +0800
fix(dev): validate integration version bumps
---
dev/src/release/bump.rs | 425 +++++++++++++++++++++++++++++++++++
dev/src/release/mod.rs | 3 +
dev/src/release/package.rs | 54 ++++-
website/community/release/release.md | 6 +
4 files changed, 486 insertions(+), 2 deletions(-)
diff --git a/dev/src/release/bump.rs b/dev/src/release/bump.rs
new file mode 100644
index 000000000..d516694e0
--- /dev/null
+++ b/dev/src/release/bump.rs
@@ -0,0 +1,425 @@
+// 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")
+ );
+ }
+}
diff --git a/dev/src/release/mod.rs b/dev/src/release/mod.rs
index 982b50f20..04463ad24 100644
--- a/dev/src/release/mod.rs
+++ b/dev/src/release/mod.rs
@@ -22,10 +22,13 @@ use sha2::{Digest, Sha512};
use std::io::BufReader;
use std::io::Read;
+mod bump;
mod package;
pub fn update_version() -> anyhow::Result<()> {
let packages = package::all_packages();
+ bump::validate_release_versions(&packages)?;
+
let mut updated = false;
for package in packages {
updated |= package::update_package_version(&package);
diff --git a/dev/src/release/package.rs b/dev/src/release/package.rs
index b6ac3514d..526de8020 100644
--- a/dev/src/release/package.rs
+++ b/dev/src/release/package.rs
@@ -27,6 +27,7 @@ pub struct Package {
path: PathBuf,
version: Version,
dependencies: Vec<Package>,
+ public_compat_dependencies: &'static [&'static str],
}
impl Package {
@@ -38,6 +39,30 @@ impl Package {
&self.name
}
+ pub(super) fn path(&self) -> &Path {
+ &self.path
+ }
+
+ pub(super) fn version(&self) -> &Version {
+ &self.version
+ }
+
+ pub(super) fn public_compat_dependencies(&self) -> &'static [&'static str] {
+ self.public_compat_dependencies
+ }
+
+ pub(super) fn release_dependency_version(&self, crate_name: &str) -> Option<&Version> {
+ self.dependencies
+ .iter()
+ .find(|dependency| dependency.crate_name() == Some(crate_name))
+ .map(|dependency| &dependency.version)
+ }
+
+ fn with_public_compat_dependencies(mut self, dependencies: &'static [&'static str]) -> Self {
+ self.public_compat_dependencies = dependencies;
+ self
+ }
+
pub fn make_prefix(&self) -> String {
format!(
"apache-opendal-{}-{}-src",
@@ -56,6 +81,7 @@ fn make_package(path: &str, version: &str, dependencies: Vec<Package>) -> Packag
path,
version,
dependencies,
+ public_compat_dependencies: &[],
}
}
@@ -65,8 +91,10 @@ pub fn all_packages() -> Vec<Package> {
// Integrations
let dav_server = make_package("integrations/dav-server", "0.7.5", vec![core.clone()]);
- let object_store = make_package("integrations/object_store", "0.59.0", vec![core.clone()]);
- let parquet = make_package("integrations/parquet", "0.9.1", vec![core.clone()]);
+ let object_store = make_package("integrations/object_store", "0.59.0", vec![core.clone()])
+ .with_public_compat_dependencies(&["opendal", "object_store"]);
+ let parquet = make_package("integrations/parquet", "0.9.1", vec![core.clone()])
+ .with_public_compat_dependencies(&["opendal", "parquet"]);
let unftp_sbe = make_package("integrations/unftp-sbe", "0.4.5", vec![core.clone()]);
// Binaries moved to separate repositories; no longer released from this repo
@@ -601,6 +629,27 @@ mod tests {
assert_eq!(parquet.version, cargo_manifest_version(&parquet));
}
+ #[test]
+ fn integrations_track_public_compatibility_dependencies() {
+ let core = release_package("core");
+ let object_store = release_package("integrations/object_store");
+ let parquet = release_package("integrations/parquet");
+
+ assert_eq!(
+ object_store.public_compat_dependencies(),
+ ["opendal", "object_store"]
+ );
+ assert_eq!(parquet.public_compat_dependencies(), ["opendal", "parquet"]);
+ assert_eq!(
+ object_store.release_dependency_version("opendal"),
+ Some(core.version())
+ );
+ assert_eq!(
+ parquet.release_dependency_version("opendal"),
+ Some(core.version())
+ );
+ }
+
#[test]
fn ruby_release_version_matches_manifest() {
let ruby = release_package("bindings/ruby");
@@ -679,6 +728,7 @@ opendal = { version = "0.55.0", path = "../core" }
path: dir.join("../../core"),
version: Version::parse("0.56.0").unwrap(),
dependencies: vec![],
+ public_compat_dependencies: &[],
};
let updated = update_cargo_version(
diff --git a/website/community/release/release.md b/website/community/release/release.md
index a292396d0..ca9720667 100644
--- a/website/community/release/release.md
+++ b/website/community/release/release.md
@@ -88,6 +88,12 @@ For example:
Run `just update-version` to bump the version in the project.
+Before updating any files, this command compares public compatibility dependencies
+against the latest reachable final release tag. It rejects an integration target
+that uses a patch bump after `opendal` or another configured public dependency
+crosses a Cargo-incompatible version boundary. Fetch the repository tags before
+running the command.
+
### Update docs
- Update `CHANGELOG.md`, refer to [Generate Release Note](reference/generate_release_note.md) for more information.
(opendal) 02/02: Merge remote-tracking branch 'origin/main' into xuanwo/validate-integration-version-bumps
Posted by xu...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
Xuanwo pushed a commit to branch xuanwo/validate-integration-version-bumps
in repository https://gitbox.apache.org/repos/asf/opendal.git
commit fc98a3a805bcb74d4e457e3be385226698124de3
Merge: 8f09e3599 55530e90e
Author: Xuanwo <gi...@xuanwo.io>
AuthorDate: Tue Aug 25 17:14:30 2026 +0800
Merge remote-tracking branch 'origin/main' into xuanwo/validate-integration-version-bumps
.github/services/s3/aws_s3_express/action.yml | 33 ++
.../OpenDAL.Tests/Behavior/DeleteBehaviorTest.cs | 42 ++
bindings/dotnet/OpenDAL/Capability.cs | 6 +
.../Interop/NativeObject/OpenDALCapability.cs | 1 +
bindings/dotnet/OpenDAL/Options/DeleteOptions.cs | 12 +
bindings/dotnet/src/capability.rs | 2 +
bindings/dotnet/src/options.rs | 2 +-
bindings/ruby/Cargo.lock | 37 +-
core/Cargo.lock | 18 +-
core/core/src/layers/simulate.rs | 51 +-
core/services/s3/Cargo.toml | 7 +-
core/services/s3/src/backend.rs | 627 ++++++++++++++++++++-
core/services/s3/src/copier.rs | 5 +-
core/services/s3/src/core.rs | 130 ++++-
core/services/s3/src/docs.md | 32 ++
core/services/s3/src/writer.rs | 15 +-
16 files changed, 940 insertions(+), 80 deletions(-)