This is an automated email from the ASF dual-hosted git repository.

xuanwo pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/opendal.git


The following commit(s) were added to refs/heads/main by this push:
     new ab9274cf1 ci: publish all rust crates in dependency order (#7352)
ab9274cf1 is described below

commit ab9274cf197287239132ef02f6f73327c05a59bb
Author: Xuanwo <[email protected]>
AuthorDate: Sat Apr 4 02:16:39 2026 +0800

    ci: publish all rust crates in dependency order (#7352)
    
    * ci: publish all split Rust crates
    
    * ci: publish all rust crates in dependency order
    
    * ci: extract rust release planning script
---
 .github/scripts/release_rust/README.md    |  49 +++++++++
 .github/scripts/release_rust/plan.py      | 176 ++++++++++++++++++++++++++++++
 .github/scripts/release_rust/test_plan.py | 135 +++++++++++++++++++++++
 .github/workflows/release_rust.yml        |  62 ++++++-----
 4 files changed, 394 insertions(+), 28 deletions(-)

diff --git a/.github/scripts/release_rust/README.md 
b/.github/scripts/release_rust/README.md
new file mode 100644
index 000000000..8840d7abf
--- /dev/null
+++ b/.github/scripts/release_rust/README.md
@@ -0,0 +1,49 @@
+# Release Rust Plan
+
+This directory contains the planning logic for the Rust crates.io release 
workflow.
+
+## Why this script exists
+
+After the repository split, Rust crates are no longer represented by a short 
hand-maintained list.
+The release workflow needs to:
+
+- discover all publishable Rust crates under `core/` and `integrations/`
+- exclude crates with `publish = false`
+- publish them in dependency order so local path dependencies are already 
available on crates.io
+
+Keeping this logic in a standalone script makes it testable and keeps the 
workflow YAML readable.
+
+## Planned crate roots
+
+The planner scans:
+
+- `core/Cargo.toml`
+- `core/core/Cargo.toml`
+- `core/layers/*/Cargo.toml`
+- `core/services/*/Cargo.toml`
+- `integrations/*/Cargo.toml`
+
+It reads local `dependencies`, `build-dependencies`, and target-specific 
variants of those tables
+to build a dependency graph, then emits a deterministic topological order.
+
+## Usage
+
+Print the publish order as JSON:
+
+```bash
+python3 .github/scripts/release_rust/plan.py
+```
+
+Write the same JSON to GitHub Actions output as `packages=<json>`:
+
+```bash
+python3 .github/scripts/release_rust/plan.py --github-output
+```
+
+## Tests
+
+Run the unit tests with:
+
+```bash
+python3 .github/scripts/release_rust/test_plan.py
+```
diff --git a/.github/scripts/release_rust/plan.py 
b/.github/scripts/release_rust/plan.py
new file mode 100644
index 000000000..c7c15c850
--- /dev/null
+++ b/.github/scripts/release_rust/plan.py
@@ -0,0 +1,176 @@
+#!/usr/bin/env python3
+# 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.
+
+import argparse
+import json
+import os
+import tomllib
+from collections import defaultdict, deque
+from dataclasses import dataclass
+from pathlib import Path
+
+
+SCRIPT_PATH = Path(__file__).resolve()
+PROJECT_DIR = SCRIPT_PATH.parents[3]
+PUBLISH_GLOBS = (
+    "core/Cargo.toml",
+    "core/core/Cargo.toml",
+    "core/layers/*/Cargo.toml",
+    "core/services/*/Cargo.toml",
+    "integrations/*/Cargo.toml",
+)
+
+
+@dataclass(frozen=True)
+class Package:
+    manifest_path: Path
+    manifest_dir: Path
+    path: str
+
+
+def discover_publishable_packages(project_dir: Path) -> dict[Path, Package]:
+    packages: dict[Path, Package] = {}
+
+    for pattern in PUBLISH_GLOBS:
+        for manifest_path in sorted(project_dir.glob(pattern)):
+            with manifest_path.open("rb") as fp:
+                manifest = tomllib.load(fp)
+
+            package = manifest.get("package")
+            if not package or package.get("publish") is False:
+                continue
+
+            manifest_dir = manifest_path.parent.resolve()
+            packages[manifest_dir] = Package(
+                manifest_path=manifest_path.resolve(),
+                manifest_dir=manifest_dir,
+                path=manifest_dir.relative_to(project_dir).as_posix(),
+            )
+
+    return packages
+
+
+def iter_local_dependencies(manifest: dict, manifest_dir: Path) -> list[Path]:
+    dependencies: list[Path] = []
+
+    def visit_table(table: dict | None) -> None:
+        if not isinstance(table, dict):
+            return
+
+        for dependency in table.values():
+            if not isinstance(dependency, dict):
+                continue
+            path = dependency.get("path")
+            if not isinstance(path, str):
+                continue
+            dependencies.append((manifest_dir / path).resolve())
+
+    for name in ("dependencies", "build-dependencies"):
+        visit_table(manifest.get(name))
+
+    for target in manifest.get("target", {}).values():
+        if not isinstance(target, dict):
+            continue
+        for name in ("dependencies", "build-dependencies"):
+            visit_table(target.get(name))
+
+    return dependencies
+
+
+def plan(project_dir: Path = PROJECT_DIR) -> list[str]:
+    project_dir = project_dir.resolve()
+    packages = discover_publishable_packages(project_dir)
+
+    graph: dict[Path, set[Path]] = defaultdict(set)
+    indegree = {manifest_dir: 0 for manifest_dir in packages}
+
+    for manifest_dir, package in packages.items():
+        with package.manifest_path.open("rb") as fp:
+            manifest = tomllib.load(fp)
+
+        for dependency_dir in iter_local_dependencies(manifest, manifest_dir):
+            if dependency_dir not in packages:
+                continue
+            if manifest_dir in graph[dependency_dir]:
+                continue
+
+            graph[dependency_dir].add(manifest_dir)
+            indegree[manifest_dir] += 1
+
+    queue = deque(
+        sorted(
+            (manifest_dir for manifest_dir, degree in indegree.items() if 
degree == 0),
+            key=lambda manifest_dir: packages[manifest_dir].path,
+        )
+    )
+
+    ordered: list[str] = []
+    while queue:
+        manifest_dir = queue.popleft()
+        ordered.append(packages[manifest_dir].path)
+
+        for dependent in sorted(
+            graph[manifest_dir], key=lambda dependent: packages[dependent].path
+        ):
+            indegree[dependent] -= 1
+            if indegree[dependent] == 0:
+                queue.append(dependent)
+
+    if len(ordered) != len(packages):
+        raise RuntimeError("failed to resolve publish order for Rust crates")
+
+    return ordered
+
+
+def write_github_output(packages: list[str]) -> None:
+    github_output = os.environ.get("GITHUB_OUTPUT")
+    if not github_output:
+        raise RuntimeError("GITHUB_OUTPUT is not set")
+
+    with Path(github_output).open("a", encoding="utf-8") as fp:
+        fp.write(f"packages={json.dumps(packages)}\n")
+
+
+def main() -> int:
+    parser = argparse.ArgumentParser(
+        description="Plan the publish order for Rust crates released from this 
repository."
+    )
+    parser.add_argument(
+        "--project-dir",
+        type=Path,
+        default=PROJECT_DIR,
+        help="Path to the repository root.",
+    )
+    parser.add_argument(
+        "--github-output",
+        action="store_true",
+        help="Write the planned package list to GITHUB_OUTPUT as 
`packages=<json>`.",
+    )
+    args = parser.parse_args()
+
+    packages = plan(args.project_dir)
+    print(json.dumps(packages))
+
+    if args.github_output:
+        write_github_output(packages)
+
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())
diff --git a/.github/scripts/release_rust/test_plan.py 
b/.github/scripts/release_rust/test_plan.py
new file mode 100644
index 000000000..f7c227d09
--- /dev/null
+++ b/.github/scripts/release_rust/test_plan.py
@@ -0,0 +1,135 @@
+# 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.
+
+import tempfile
+import textwrap
+import unittest
+from pathlib import Path
+
+from plan import plan
+
+
+def write_manifest(path: Path, content: str) -> None:
+    path.parent.mkdir(parents=True, exist_ok=True)
+    path.write_text(textwrap.dedent(content).strip() + "\n", encoding="utf-8")
+
+
+class ReleaseRustPlanTest(unittest.TestCase):
+    def test_fixture_plan_orders_path_dependencies(self):
+        with tempfile.TemporaryDirectory() as tmpdir:
+            root = Path(tmpdir)
+
+            write_manifest(
+                root / "core" / "core" / "Cargo.toml",
+                """
+                [package]
+                name = "opendal-core"
+                version = "0.1.0"
+                """,
+            )
+            write_manifest(
+                root / "core" / "layers" / "retry" / "Cargo.toml",
+                """
+                [package]
+                name = "opendal-layer-retry"
+                version = "0.1.0"
+
+                [dependencies]
+                opendal-core = { path = "../../core", version = "0.1.0" }
+                """,
+            )
+            write_manifest(
+                root / "core" / "services" / "fs" / "Cargo.toml",
+                """
+                [package]
+                name = "opendal-service-fs"
+                version = "0.1.0"
+
+                [dependencies]
+                opendal-core = { path = "../../core", version = "0.1.0" }
+                """,
+            )
+            write_manifest(
+                root / "core" / "Cargo.toml",
+                """
+                [package]
+                name = "opendal"
+                version = "0.1.0"
+
+                [dependencies]
+                opendal-core = { path = "core", version = "0.1.0" }
+                opendal-layer-retry = { path = "layers/retry", version = 
"0.1.0" }
+
+                [target.'cfg(unix)'.build-dependencies]
+                opendal-service-fs = { path = "services/fs", version = "0.1.0" 
}
+                """,
+            )
+            write_manifest(
+                root / "integrations" / "object_store" / "Cargo.toml",
+                """
+                [package]
+                name = "object_store_opendal"
+                version = "0.1.0"
+
+                [dependencies]
+                opendal = { path = "../../core", version = "0.1.0" }
+                """,
+            )
+            write_manifest(
+                root / "core" / "services" / "private" / "Cargo.toml",
+                """
+                [package]
+                name = "opendal-service-private"
+                version = "0.1.0"
+                publish = false
+                """,
+            )
+
+            result = plan(root)
+            self.assertEqual(
+                result,
+                [
+                    "core/core",
+                    "core/layers/retry",
+                    "core/services/fs",
+                    "core",
+                    "integrations/object_store",
+                ],
+            )
+
+    def test_repository_plan_excludes_non_release_paths(self):
+        result = plan()
+
+        self.assertIn("core/core", result)
+        self.assertIn("core", result)
+        self.assertIn("integrations/object_store", result)
+
+        self.assertNotIn("bindings/python", result)
+        self.assertNotIn("core/testkit", result)
+        self.assertNotIn("core/examples/basic", result)
+
+    def test_repository_plan_orders_core_before_root_and_integrations(self):
+        result = plan()
+
+        self.assertLess(result.index("core/core"), result.index("core"))
+        self.assertLess(result.index("core"), 
result.index("integrations/object_store"))
+        self.assertLess(result.index("core"), 
result.index("integrations/parquet"))
+        self.assertLess(result.index("core"), 
result.index("integrations/unftp-sbe"))
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/.github/workflows/release_rust.yml 
b/.github/workflows/release_rust.yml
index fefab0605..549e7d963 100644
--- a/.github/workflows/release_rust.yml
+++ b/.github/workflows/release_rust.yml
@@ -15,7 +15,7 @@
 # specific language governing permissions and limitations
 # under the License.
 
-# This workflow is used for publish all rust based packages
+# This workflow publishes all Rust packages that are intended for crates.io.
 
 name: Release Rust Packages
 
@@ -30,23 +30,26 @@ on:
       - ".github/workflows/release_rust.yml"
   workflow_dispatch:
 
+permissions:
+  contents: read
+
 jobs:
+  plan:
+    runs-on: ubuntu-latest
+    outputs:
+      packages: ${{ steps.plan.outputs.packages }}
+    steps:
+      - uses: actions/checkout@v6
+      - name: Plan publish matrix
+        id: plan
+        run: python3 .github/scripts/release_rust/plan.py --github-output
+
   publish:
+    if: ${{ startsWith(github.ref, 'refs/tags/') && !contains(github.ref, '-') 
}}
+    needs: plan
     runs-on: ubuntu-latest
     permissions:
       id-token: write
-    strategy:
-      # Publish package one by one instead of flooding the registry
-      max-parallel: 1
-      fail-fast: false
-      matrix:
-        # Order here is sensitive, as it will be used to determine the order 
of publishing
-        package:
-          - "core"
-          - "integrations/object_store"
-          - "integrations/parquet"
-          - "integrations/dav-server"
-          - "integrations/unftp-sbe"
     steps:
       - uses: actions/checkout@v6
       - name: Checkout python env
@@ -58,28 +61,31 @@ jobs:
         with:
           distribution: zulu
           java-version: 25
-
       - name: Setup Rust toolchain
         uses: ./.github/actions/setup
         with:
           need-rocksdb: true
           need-protoc: true
-
-      - name: Dryrun ${{ matrix.package }}
-        # Only dryrun test upon core.
-        if: matrix.package == 'core'
-        working-directory: ${{ matrix.package }}
-        run: cargo publish --dry-run
-        env:
-          LD_LIBRARY_PATH: ${{ env.JAVA_HOME }}/lib/server:${{ 
env.LD_LIBRARY_PATH }}
-
       - uses: 
rust-lang/crates-io-auth-action@b7e9a28eded4986ec6b1fa40eeee8f8f165559ec
         id: auth
-      - name: Publish ${{ matrix.package }}
-        working-directory: ${{ matrix.package }}
-        # Only publish if it's a tag and the tag is not a pre-release
-        if: ${{ startsWith(github.ref, 'refs/tags/') && !contains(github.ref, 
'-') }}
-        run: cargo publish --no-verify
+      - name: Publish Rust crates
+        shell: python
         env:
+          PACKAGES: ${{ needs.plan.outputs.packages }}
           LD_LIBRARY_PATH: ${{ env.JAVA_HOME }}/lib/server:${{ 
env.LD_LIBRARY_PATH }}
           CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }}
+        run: |
+          import json
+          import os
+          import subprocess
+
+          packages = json.loads(os.environ["PACKAGES"])
+
+          for package in packages:
+              print(f"Publishing {package}")
+              subprocess.run(
+                  ["cargo", "publish", "--no-verify"],
+                  cwd=package,
+                  check=True,
+                  env=os.environ,
+              )

Reply via email to