From 61183b937d4c2ace109f8d02a95c92f57c0d8cdf Mon Sep 17 00:00:00 2001 From: nachiketb Date: Wed, 1 Jul 2026 13:11:14 -0700 Subject: [PATCH] ci(release): add Rust crate publish workflow --- .agents/skills/publish-package/SKILL.md | 22 ++- .github/workflows/publish.yml | 55 +++++- Cargo.toml | 2 + .../Cargo.toml | 2 + crates/switchyard-components-v2/Cargo.toml | 8 +- crates/switchyard-components/Cargo.toml | 6 +- crates/switchyard-core/Cargo.toml | 2 + crates/switchyard-py/Cargo.toml | 3 + crates/switchyard-server/Cargo.toml | 8 +- crates/switchyard-translation/Cargo.toml | 2 + docs/internal/release_workflow.md | 81 +++++++- scripts/release/publish_crates.py | 185 ++++++++++++++++++ tests/test_rust_crate_publishing.py | 44 +++++ 13 files changed, 403 insertions(+), 17 deletions(-) create mode 100644 scripts/release/publish_crates.py create mode 100644 tests/test_rust_crate_publishing.py diff --git a/.agents/skills/publish-package/SKILL.md b/.agents/skills/publish-package/SKILL.md index e8165523..1eaadba9 100644 --- a/.agents/skills/publish-package/SKILL.md +++ b/.agents/skills/publish-package/SKILL.md @@ -12,21 +12,27 @@ wheels separate from official tag-gated release builds. |---|---|---|---|---| | Dev wheel artifact | Manual `publish.yml` dispatch with `build_dev_artifact=true` | GitHub Actions | One-day GitHub artifact | `docs/internal/release_workflow.md` | | Dev matrix artifact | Manual `publish.yml` dispatch with `build_dev_matrix=true` | GitHub Actions | Full matrix GitHub artifacts | `docs/internal/release_workflow.md` | -| Official release build | Root `vMAJOR.MINOR.PATCH` tag | GitHub Actions `.github/workflows/publish.yml` | Full release artifact matrix + PyPI Trusted Publishing via `uv publish` | `.github/workflows/publish.yml` | +| Rust crate package dry-run | Manual `publish.yml` dispatch with `package_rust_crates=true` | GitHub Actions | `.crate` artifacts only | `docs/internal/release_workflow.md` | +| First Rust crate publish | Manual `publish.yml` dispatch from `main` with `publish_rust_crates=true` | GitHub Actions | crates.io via `cargo publish` | `docs/internal/release_workflow.md` | +| Official release build | Root `vMAJOR.MINOR.PATCH` tag | GitHub Actions `.github/workflows/publish.yml` | Full release artifact matrix + crates.io + PyPI Trusted Publishing via `uv publish` | `.github/workflows/publish.yml` | -Release publishing stays on the public GitHub/PyPI path. Manual branch builds only produce -artifacts; root release tags publish through PyPI Trusted Publishing. +Release publishing stays on the public GitHub/PyPI/crates.io path. Manual branch builds only +produce artifacts; root release tags publish through PyPI Trusted Publishing and crates.io. ## Guardrails - Do not create tags unless the user explicitly asks for a tag-based release. - Do not create GitHub Releases for dev wheel artifacts. - Do not publish dev wheels to PyPI from manual workflow dispatch. +- Do not publish Rust crates from branches. Manual Rust crate publishing is allowed only from + `main` and must use the `crates-io` GitHub environment. - Keep `.dev` artifacts public-safe because GitHub Actions artifacts may be shared for review. - Full wheel matrices may run manually for validation, but PyPI publishing belongs only on root `vMAJOR.MINOR.PATCH` tag releases. - Manual dev builds should build exactly one Linux x86_64 wheel artifact with one-day retention. - Use PyPI Trusted Publishing with the GitHub environment named `pypi`; do not add long-lived PyPI tokens. +- Use a crates.io API token only in the GitHub environment named `crates-io`, stored as + `CARGO_REGISTRY_TOKEN`. ## Dev Wheel Artifact Shape @@ -40,6 +46,8 @@ and `Version`. |---|---|---| | `build_dev_artifact` | `false` | Set to `true` to build one temporary wheel artifact | | `build_dev_matrix` | `false` | Set to `true` to build the complete sdist and wheel matrix as artifacts | +| `package_rust_crates` | `false` | Set to `true` to run `cargo publish --dry-run` and upload `.crate` artifacts | +| `publish_rust_crates` | `false` | Set to `true` from `main` to publish the first Rust crates after approval | | `dev_version` | `0.0.1.dev0` | PEP 440 `.dev` version for wheel metadata | ## Required Secrets @@ -49,6 +57,10 @@ The artifact-only dev build does not require release secrets. The official publish job uses `uv publish --trusted-publishing always`; no PyPI token is required. Manual dev builds never publish to PyPI. +Rust crate publishing uses `cargo publish`, so the `crates-io` GitHub environment must define +`CARGO_REGISTRY_TOKEN`. `switchyard-py` is intentionally not published to crates.io because it is +distributed inside the Python wheel. + Before cutting the first tag, create the pending PyPI trusted publisher for: | Field | Value | @@ -67,6 +79,7 @@ When editing release scripts, package metadata, package release docs, or this sk uv run ruff check scripts/release/set_dev_wheel_version.py tests/test_dev_wheel_versioning.py uv run pytest tests/test_dev_wheel_versioning.py -v python scripts/release/set_dev_wheel_version.py 0.0.1.dev0 --print-version +python scripts/release/publish_crates.py git diff --check ``` @@ -84,6 +97,8 @@ make publish | GitHub artifact verifier cannot find wheels | Check the `dev-wheel-*` artifact names and retention | | Artifact build creates a `switchyard` wheel | Confirm `scripts/release/set_dev_wheel_version.py` ran before `maturin build` | | Release publish cannot mint trusted token | Confirm PyPI pending publisher and GitHub `pypi` environment both match the workflow | +| Rust crate publish cannot authenticate | Confirm `CARGO_REGISTRY_TOKEN` is set in the `crates-io` environment | +| Rust crate dry-run rejects path dependencies | Confirm internal published dependencies include both `path = ...` and `version = "..."` | | Full matrix runs on every commit | Ensure matrix jobs are only `workflow_dispatch` or root release-tag gated | | Install imports checkout version | Verify from a temporary directory outside the repo | @@ -91,4 +106,5 @@ make publish - Release workflow runbook: `docs/internal/release_workflow.md` - Dev wheel version helper: `scripts/release/set_dev_wheel_version.py` +- Rust crate publish helper: `scripts/release/publish_crates.py` - Public GitHub build workflow: `.github/workflows/publish.yml` diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 5341af10..a2126c8a 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -16,6 +16,16 @@ on: required: true type: boolean default: false + package_rust_crates: + description: "Run cargo publish --dry-run for public Rust crates and upload .crate artifacts." + required: true + type: boolean + default: false + publish_rust_crates: + description: "Publish public Rust crates to crates.io from main. Requires environment approval." + required: true + type: boolean + default: false dev_version: description: "PEP 440 .dev version for the dev wheel." required: true @@ -32,12 +42,14 @@ permissions: jobs: # OSS-style split: # - Root vMAJOR.MINOR.PATCH tags run validation, build the complete matrix, publish to - # PyPI, and create a GitHub Release. + # crates.io and PyPI, and create a GitHub Release. # - Manual dev builds create artifacts only. They are useful for branch validation but never # publish to PyPI. + # - Manual Rust crate publishing is allowed only from main and still requires the protected + # crates-io environment. # - # This deliberately keeps release publishing on the public GitHub/PyPI path. Manual - # branch builds only produce artifacts; tag builds publish through PyPI Trusted Publishing. + # This deliberately keeps release publishing on the public GitHub/PyPI/crates.io path. Manual + # branch builds only produce artifacts; tag builds publish through trusted/secret-backed paths. validate-release-ref: name: Validate release ref if: ${{ (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')) || github.event_name == 'workflow_dispatch' }} @@ -66,6 +78,14 @@ jobs: ) print(f"release tag {tag} matches pyproject version {package_version}") PY + - name: Check manual Rust crate publish ref + if: ${{ github.event_name == 'workflow_dispatch' && inputs.publish_rust_crates }} + shell: bash + run: | + if [ "${GITHUB_REF}" != "refs/heads/main" ]; then + echo "Rust crate publishing from workflow_dispatch must run from main, got ${GITHUB_REF}" >&2 + exit 1 + fi python-release-checks: name: Release Python checks (${{ matrix.python-version }}) @@ -105,6 +125,33 @@ jobs: - name: cargo test run: cargo test --workspace -- --test-threads=1 + rust-crate-package: + name: Rust crate package dry-run + if: ${{ (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')) || (github.event_name == 'workflow_dispatch' && (inputs.build_dev_matrix || inputs.package_rust_crates || inputs.publish_rust_crates)) }} + needs: [validate-release-ref] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Package public Rust crates + run: python scripts/release/publish_crates.py + - uses: actions/upload-artifact@v4 + with: + name: switchyard-rust-crates + path: target/package/*.crate + + publish-rust-crates: + name: Publish Rust crates + if: ${{ (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')) || (github.event_name == 'workflow_dispatch' && inputs.publish_rust_crates) }} + needs: [rust-crate-package] + runs-on: ubuntu-latest + environment: crates-io + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + steps: + - uses: actions/checkout@v4 + - name: Publish public Rust crates + run: python scripts/release/publish_crates.py --publish + source-dist: name: Source distribution if: ${{ (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')) || (github.event_name == 'workflow_dispatch' && inputs.build_dev_matrix) }} @@ -422,7 +469,7 @@ jobs: publish: name: Publish distributions if: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') }} - needs: [source-dist, wheels] + needs: [source-dist, wheels, publish-rust-crates] runs-on: ubuntu-latest environment: pypi permissions: diff --git a/Cargo.toml b/Cargo.toml index 56e23bbf..6b899e08 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,8 @@ members = [ [workspace.package] authors = ["NVIDIA Corporation"] edition = "2021" +homepage = "https://github.com/NVIDIA-NeMo/Switchyard" +keywords = ["switchyard", "llm", "routing"] license = "Apache-2.0" repository = "https://github.com/NVIDIA-NeMo/Switchyard" rust-version = "1.94" diff --git a/crates/switchyard-components-v2-macros/Cargo.toml b/crates/switchyard-components-v2-macros/Cargo.toml index 0ea8c8e8..c30467b1 100644 --- a/crates/switchyard-components-v2-macros/Cargo.toml +++ b/crates/switchyard-components-v2-macros/Cargo.toml @@ -7,6 +7,8 @@ version = "0.1.0" description = "Macros for experimental Switchyard components-v2 profile definitions" authors.workspace = true edition.workspace = true +homepage.workspace = true +keywords.workspace = true license.workspace = true repository.workspace = true rust-version.workspace = true diff --git a/crates/switchyard-components-v2/Cargo.toml b/crates/switchyard-components-v2/Cargo.toml index d257a065..b97b0b3f 100644 --- a/crates/switchyard-components-v2/Cargo.toml +++ b/crates/switchyard-components-v2/Cargo.toml @@ -7,6 +7,8 @@ version = "0.1.0" description = "Experimental flatter Switchyard component profile runtime" authors.workspace = true edition.workspace = true +homepage.workspace = true +keywords.workspace = true license.workspace = true repository.workspace = true rust-version.workspace = true @@ -18,9 +20,9 @@ rand = "0.8" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls-native-roots"] } serde = { version = "1", features = ["derive"] } serde_json = "1" -switchyard-components = { path = "../switchyard-components" } -switchyard-components-v2-macros = { path = "../switchyard-components-v2-macros" } -switchyard-core = { path = "../switchyard-core" } +switchyard-components = { path = "../switchyard-components", version = "0.1.0" } +switchyard-components-v2-macros = { path = "../switchyard-components-v2-macros", version = "0.1.0" } +switchyard-core = { path = "../switchyard-core", version = "0.1.0" } toml = "0.8" tracing = { version = "0.1", default-features = false, features = ["std"] } yaml_serde = "0.10" diff --git a/crates/switchyard-components/Cargo.toml b/crates/switchyard-components/Cargo.toml index 58550381..c01e885f 100644 --- a/crates/switchyard-components/Cargo.toml +++ b/crates/switchyard-components/Cargo.toml @@ -7,6 +7,8 @@ version = "0.1.0" description = "Concrete Switchyard backends and processors" authors.workspace = true edition.workspace = true +homepage.workspace = true +keywords.workspace = true license.workspace = true repository.workspace = true rust-version.workspace = true @@ -20,8 +22,8 @@ rand = "0.8" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls-native-roots", "stream"] } serde = { version = "1", features = ["derive"] } serde_json = "1" -switchyard-core = { path = "../switchyard-core" } -switchyard-translation = { path = "../switchyard-translation" } +switchyard-core = { path = "../switchyard-core", version = "0.1.0" } +switchyard-translation = { path = "../switchyard-translation", version = "0.1.0" } tokio = { version = "1", features = ["rt", "sync"] } tracing = { version = "0.1", default-features = false, features = ["std"] } diff --git a/crates/switchyard-core/Cargo.toml b/crates/switchyard-core/Cargo.toml index 177633e4..8b58e65f 100644 --- a/crates/switchyard-core/Cargo.toml +++ b/crates/switchyard-core/Cargo.toml @@ -7,6 +7,8 @@ version = "0.1.0" description = "Core contracts for Switchyard's Rust implementation" authors.workspace = true edition.workspace = true +homepage.workspace = true +keywords.workspace = true license.workspace = true repository.workspace = true rust-version.workspace = true diff --git a/crates/switchyard-py/Cargo.toml b/crates/switchyard-py/Cargo.toml index 67f59cca..83a547c1 100644 --- a/crates/switchyard-py/Cargo.toml +++ b/crates/switchyard-py/Cargo.toml @@ -7,7 +7,10 @@ version = "0.1.0" description = "PyO3 bindings for Switchyard Rust crates" authors.workspace = true edition.workspace = true +homepage.workspace = true +keywords.workspace = true license.workspace = true +publish = false repository.workspace = true rust-version.workspace = true diff --git a/crates/switchyard-server/Cargo.toml b/crates/switchyard-server/Cargo.toml index 6988a1fb..af4f8b19 100644 --- a/crates/switchyard-server/Cargo.toml +++ b/crates/switchyard-server/Cargo.toml @@ -7,6 +7,8 @@ version = "0.1.0" description = "Rust HTTP server surface for Switchyard profiles" authors.workspace = true edition.workspace = true +homepage.workspace = true +keywords.workspace = true license.workspace = true repository.workspace = true rust-version.workspace = true @@ -18,9 +20,9 @@ clap = { version = "4", features = ["derive", "env"] } futures-util = "0.3" serde = { version = "1", features = ["derive"] } serde_json = "1" -switchyard-components-v2 = { path = "../switchyard-components-v2" } -switchyard-core = { path = "../switchyard-core" } -switchyard-translation = { path = "../switchyard-translation" } +switchyard-components-v2 = { path = "../switchyard-components-v2", version = "0.1.0" } +switchyard-core = { path = "../switchyard-core", version = "0.1.0" } +switchyard-translation = { path = "../switchyard-translation", version = "0.1.0" } tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "signal"] } tracing = { version = "0.1", default-features = false, features = ["std"] } diff --git a/crates/switchyard-translation/Cargo.toml b/crates/switchyard-translation/Cargo.toml index 4f7d7e9f..8480e81c 100644 --- a/crates/switchyard-translation/Cargo.toml +++ b/crates/switchyard-translation/Cargo.toml @@ -7,6 +7,8 @@ version = "0.1.0" description = "Pure Rust request, response, and stream translation for Switchyard" authors.workspace = true edition.workspace = true +homepage.workspace = true +keywords.workspace = true license.workspace = true repository.workspace = true rust-version.workspace = true diff --git a/docs/internal/release_workflow.md b/docs/internal/release_workflow.md index b6a44b7f..daaaee0e 100644 --- a/docs/internal/release_workflow.md +++ b/docs/internal/release_workflow.md @@ -7,8 +7,10 @@ Switchyard currently follows the OSS-style NeMo path for GitHub builds: - regular CI runs tests, linting, type checks, Rust checks, and slim-install smoke checks; - manual dev builds create one Linux x86_64 wheel as a one-day GitHub Actions artifact; - manual dev matrix builds create the full sdist and wheel set as GitHub Actions artifacts; +- manual Rust crate package runs create `.crate` artifacts without publishing; - root `vMAJOR.MINOR.PATCH` tags run the complete release validation and wheel matrix; -- public PyPI/GitHub publishing happens only from approved `vMAJOR.MINOR.PATCH` tag releases. +- public PyPI/GitHub/crates.io publishing happens only from approved `vMAJOR.MINOR.PATCH` tag + releases, except the first Rust crate publish can be dispatched from `main` after approval. Wheel metadata uses the public distribution name `nemo-switchyard`, while the Python import and CLI stay `switchyard`. @@ -57,6 +59,55 @@ This path stamps the requested `.dev` version, runs the release checks, builds t full abi3 wheel matrix, and uploads the distributions as GitHub Actions artifacts. It does not publish anything to PyPI. +## Manual Rust Crate Package Dry-Run + +Use this before the first crates.io publish or after changing Rust release metadata: + +| Input | Value | +|---|---| +| `package_rust_crates` | `true` | +| `publish_rust_crates` | `false` | + +The workflow runs `cargo publish --dry-run` for each public crate in dependency order and uploads +the generated `.crate` files as the `switchyard-rust-crates` artifact. This is safe on branches and +does not require a crates.io token. During dry-run only, the helper patches crates.io dependencies +back to local workspace paths so dependent crates can be verified before the first dependency +versions exist in the public registry. Real publishes do not use those patches. + +The public crate publish order is: + +| Order | Crate | +|---:|---| +| 1 | `switchyard-core` | +| 2 | `switchyard-translation` | +| 3 | `switchyard-components` | +| 4 | `switchyard-components-v2-macros` | +| 5 | `switchyard-components-v2` | +| 6 | `switchyard-server` | + +`switchyard-py` is intentionally not published to crates.io. It is the PyO3 extension crate shipped +inside the `nemo-switchyard` Python wheel. + +## Manual First Rust Crate Publish + +The first crates.io publish can be dispatched from `main` after the crate metadata MR has merged. +Set: + +| Input | Value | +|---|---| +| `publish_rust_crates` | `true` | + +This path is blocked unless the workflow is running from `refs/heads/main`. It also requires: + +| GitHub setting | Value | +|---|---| +| Environment | `crates-io` | +| Secret | `CARGO_REGISTRY_TOKEN` | + +Create a crates.io API token with publish rights and store it as `CARGO_REGISTRY_TOKEN` in the +`crates-io` environment. The script publishes in dependency order and pauses between uploads so the +registry index can settle before publishing dependent crates. + ## Official Release Build Create a root `vMAJOR.MINOR.PATCH` tag only when a real release has been approved. Tag pushes run: @@ -83,7 +134,19 @@ and uploads require a matching pending trusted publisher: | Environment | `pypi` | Do not create a root release tag until the PyPI pending publisher and GitHub `pypi` environment are -ready. +ready. Official tag releases also publish the public Rust crates before the Python distribution +publish job starts, so the `crates-io` environment and `CARGO_REGISTRY_TOKEN` secret must be ready +before cutting a tag. + +After Rust crate publishing completes, verify: + +| Check | URL | +|---|---| +| Crate listing | `https://crates.io/crates/` | +| Rust docs | `https://docs.rs////` | + +Capture any docs.rs failures as follow-up work instead of republishing the same version; crates.io +versions are immutable. ## Local Metadata Helper @@ -95,3 +158,17 @@ python scripts/release/set_dev_wheel_version.py 0.0.1.dev0 --package-name nemo-s ``` Do not commit the stamped package metadata unless the release process explicitly requires it. + +To validate Rust crate release metadata locally: + +```bash +python scripts/release/publish_crates.py +``` + +This runs the same `cargo publish --dry-run` sequence used by GitHub Actions. +Before committing release-infra changes, use `--allow-dirty` to test the staged manifests locally +without weakening the CI path: + +```bash +python scripts/release/publish_crates.py --allow-dirty +``` diff --git a/scripts/release/publish_crates.py b/scripts/release/publish_crates.py new file mode 100644 index 00000000..c5cf7aaf --- /dev/null +++ b/scripts/release/publish_crates.py @@ -0,0 +1,185 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Package or publish Switchyard Rust crates in dependency order.""" + +from __future__ import annotations + +import argparse +import os +import subprocess +import sys +import time +import tomllib +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] + +PUBLIC_CRATES = ( + "switchyard-core", + "switchyard-translation", + "switchyard-components", + "switchyard-components-v2-macros", + "switchyard-components-v2", + "switchyard-server", +) + +PRIVATE_CRATES = ("switchyard-py",) + + +def crate_manifest(crate: str) -> Path: + """Return the manifest path for a workspace crate.""" + return ROOT / "crates" / crate / "Cargo.toml" + + +def load_toml(path: Path) -> dict[str, object]: + """Load a TOML file with Python's standard parser.""" + with path.open("rb") as file: + return tomllib.load(file) + + +def package_table(crate: str) -> dict[str, object]: + """Return the `[package]` table for a workspace crate.""" + manifest = crate_manifest(crate) + try: + data = load_toml(manifest) + package = data["package"] + except (FileNotFoundError, KeyError, tomllib.TOMLDecodeError) as error: + raise SystemExit(f"{manifest}: failed to read package metadata: {error}") from error + if not isinstance(package, dict): + raise SystemExit(f"{manifest}: [package] must be a table") + return package + + +def dependency_table(crate: str) -> dict[str, object]: + """Return the `[dependencies]` table for a workspace crate.""" + data = load_toml(crate_manifest(crate)) + dependencies = data.get("dependencies", {}) + if not isinstance(dependencies, dict): + raise SystemExit(f"{crate}: [dependencies] must be a table") + return dependencies + + +def crate_versions() -> dict[str, str]: + """Return the version of each crate managed by this release script.""" + versions: dict[str, str] = {} + for crate in (*PUBLIC_CRATES, *PRIVATE_CRATES): + version = package_table(crate).get("version") + if not isinstance(version, str): + raise SystemExit(f"{crate}: package.version must be a string") + versions[crate] = version + return versions + + +def validate_publish_metadata() -> None: + """Validate manifest details required before packaging or publishing crates.""" + versions = crate_versions() + public_versions = {versions[crate] for crate in PUBLIC_CRATES} + if len(public_versions) != 1: + details = ", ".join(f"{crate}={versions[crate]}" for crate in PUBLIC_CRATES) + raise SystemExit(f"public crate versions must match: {details}") + + for crate in PUBLIC_CRATES: + publish = package_table(crate).get("publish") + if publish is False: + raise SystemExit(f"{crate}: package.publish=false but crate is in PUBLIC_CRATES") + + for dep_name, dep_spec in dependency_table(crate).items(): + if dep_name not in PUBLIC_CRATES: + continue + if not isinstance(dep_spec, dict): + raise SystemExit(f"{crate}: dependency {dep_name} must use a table") + if "path" not in dep_spec: + raise SystemExit(f"{crate}: dependency {dep_name} must keep a local path") + if dep_spec.get("version") != versions[dep_name]: + raise SystemExit( + f"{crate}: dependency {dep_name} must pin version {versions[dep_name]}" + ) + + for crate in PRIVATE_CRATES: + if package_table(crate).get("publish") is not False: + raise SystemExit(f"{crate}: private release crate must set package.publish=false") + + +def run(command: list[str]) -> None: + """Run one subprocess command from the repository root.""" + print("+", " ".join(command), flush=True) + subprocess.run(command, cwd=ROOT, check=True) + + +def local_patch_config(crate: str) -> list[str]: + """Return dry-run-only crates.io patches for unpublished workspace dependencies.""" + config: list[str] = [] + for dependency in dependency_table(crate): + if dependency not in PUBLIC_CRATES: + continue + config.extend( + [ + "--config", + f'patch.crates-io.{dependency}.path="crates/{dependency}"', + ] + ) + return config + + +def cargo_publish(crate: str, *, publish: bool, allow_dirty: bool) -> None: + """Run `cargo publish` for one crate, optionally as a real upload.""" + command = ["cargo", "publish", "--locked", "--package", crate] + if allow_dirty: + command.append("--allow-dirty") + if not publish: + command.append("--dry-run") + command.extend(local_patch_config(crate)) + run(command) + + +def publish_crates(*, publish: bool, pause_seconds: int, allow_dirty: bool = False) -> None: + """Package or publish all public crates in dependency order.""" + validate_publish_metadata() + if publish and allow_dirty: + raise SystemExit("--allow-dirty is only allowed for dry-run packaging") + if publish and not os.environ.get("CARGO_REGISTRY_TOKEN"): + raise SystemExit("CARGO_REGISTRY_TOKEN is required for crates.io publishing") + + for index, crate in enumerate(PUBLIC_CRATES): + cargo_publish(crate, publish=publish, allow_dirty=allow_dirty) + if publish and pause_seconds > 0 and index < len(PUBLIC_CRATES) - 1: + # Let the crates.io index settle before the next dependent crate resolves. + time.sleep(pause_seconds) + + +def parse_args(argv: list[str]) -> argparse.Namespace: + """Parse command-line arguments.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--publish", + action="store_true", + help="Upload crates to crates.io. Without this flag, run cargo publish --dry-run.", + ) + parser.add_argument( + "--pause-seconds", + type=int, + default=20, + help="Seconds to wait between real publishes so registry metadata can settle.", + ) + parser.add_argument( + "--allow-dirty", + action="store_true", + help="Allow uncommitted files during local dry-runs. Rejected with --publish.", + ) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + """Package or publish the public Rust crates.""" + args = parse_args(sys.argv[1:] if argv is None else argv) + publish_crates( + publish=args.publish, + pause_seconds=args.pause_seconds, + allow_dirty=args.allow_dirty, + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_rust_crate_publishing.py b/tests/test_rust_crate_publishing.py new file mode 100644 index 00000000..73e3d276 --- /dev/null +++ b/tests/test_rust_crate_publishing.py @@ -0,0 +1,44 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for Rust crate release metadata.""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path +from types import ModuleType + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT_PATH = ROOT / "scripts" / "release" / "publish_crates.py" + + +def load_publish_script() -> ModuleType: + """Load the release helper as a regular Python module.""" + spec = importlib.util.spec_from_file_location("publish_crates", SCRIPT_PATH) + if spec is None or spec.loader is None: + raise AssertionError("failed to load publish_crates.py") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_public_crates_are_ordered_by_dependency() -> None: + """Public crates should be published before crates that depend on them.""" + publish_crates = load_publish_script() + + assert publish_crates.PUBLIC_CRATES == ( + "switchyard-core", + "switchyard-translation", + "switchyard-components", + "switchyard-components-v2-macros", + "switchyard-components-v2", + "switchyard-server", + ) + + +def test_rust_crate_publish_metadata_is_valid() -> None: + """Release metadata should be valid before cargo publish runs.""" + publish_crates = load_publish_script() + + publish_crates.validate_publish_metadata()