Skip to main contentPublishing CLI tools, crates, and versions | IoT Worker

Publishing CLI tools, crates, and versions

When a C project is shipped to other people, the hard part is often not whether the source builds. The hard part is the contract around it:

How is the tool installed into PATH?
Where do the headers and libraries go?
Which version should users link against?
Will an upgrade break existing code?
Does a public API change count as compatible?
Can the binary ABI remain stable over time?

Rust has the same questions, but the shipped object usually falls into one of three groups:

CLI       -> something people run directly
crate     -> something other Rust projects depend on
C ABI lib -> something C or other languages link to

Do not treat these as one release model. A CLI is about installation and runtime behavior. A crate is about source-level API compatibility. A C ABI library is about symbols, layout, and dynamic linking.

CLI tools can be published with cargo install

A Rust command-line tool is usually a binary crate:

[package]
name = "device-tool"
version = "0.1.0"
edition = "2021"

[[bin]]
name = "device-tool"
path = "src/main.rs"

Local installation:

cargo install --path .

If the tool is published to crates.io, users can install it with:

cargo install device-tool

This is close to the C-world pattern:

make
make install

The difference is that Cargo builds from source and installs the executable into Cargo’s bin directory. That directory must be in PATH.

For a CLI, the important compatibility surface includes:

  • stable command name
  • backward-compatible arguments
  • stdout suitable for scripts
  • stderr reserved for diagnostics
  • stable exit codes
  • clear boundaries for config files and environment variables

For a CLI, arguments, output format, and exit codes are API.

A crate publishes source-level library API

After a Rust crate is published to crates.io, another project depends on it like this:

[dependencies]
device-core = "0.3"

This is not the same as publishing a .a or .so for someone to link against. A crate is a source package. Downstream projects rebuild it with their own target, features, profiles, and dependency graph.

That means Rust crate compatibility is mainly source-level API compatibility:

pub struct DeviceId(String);

pub fn parse_device_id(input: &str) -> Result<DeviceId, ParseError> {
    // ...
}

Once these are public, they become part of the compatibility surface:

  • public structs, enums, traits, and functions
  • public module paths
  • function parameters and return types
  • trait bounds
  • enum variants
  • feature names
  • behavior promised by documentation

In C, users depend on what a header exposes. In Rust, users depend on what pub exposes.

Cargo.toml is the release manifest

Before publishing a crate, Cargo.toml should read like a release manifest:

[package]
name = "device-core"
version = "0.3.0"
edition = "2021"
description = "Device protocol parsing and validation"
license = "MIT OR Apache-2.0"
repository = "https://github.com/example/device-core"
readme = "README.md"
keywords = ["device", "protocol"]
categories = ["command-line-utilities"]
exclude = [
    "tests/fixtures/private-*",
]

Common release checks:

cargo package
cargo publish --dry-run

cargo package builds the actual package that would be uploaded. Do not only inspect the working tree. Check whether the package includes the right files and excludes private or generated artifacts.

To inspect the package contents:

cargo package --list

This is similar to checking a C project tarball before release:

Are the headers complete?
Do examples build?
Is LICENSE included?
Are private test fixtures excluded?
Did generated files leak into the package?

Versions express compatibility promises

Rust crates usually follow semantic versioning:

MAJOR.MINOR.PATCH

The rough meaning is:

PATCH -> bug fixes without compatibility breaks
MINOR -> compatible additions
MAJOR -> compatibility-breaking changes allowed

For example:

0.3.1 -> 0.3.2  usually a patch release
0.3.1 -> 0.4.0  may add features, and in 0.x may break compatibility
1.2.3 -> 1.3.0  compatible API additions
1.2.3 -> 2.0.0  breaking API changes allowed

Rust also has the practical issue of 0.x: before 1.0.0, the compatibility promise is usually weaker. A library can stay in 0.x while the API is still settling, but once people depend on it, upgrade impact still matters.

For a C developer, the version number is the promise made by the public header. Internal implementation changes do not require a major release. Removing functions, changing types, or changing semantics may.

Cargo dependency versions are not exact pins

When downstream writes:

device-core = "1.2.3"

that usually allows compatible updates. It does not mean exactly 1.2.3 only. Cargo selects a version according to compatibility rules, and application projects use Cargo.lock to pin the final resolved graph.

A library crate usually commits Cargo.toml, but its own Cargo.lock does not control downstream resolution. An application or CLI should keep Cargo.lock so release builds are reproducible.

A practical rule:

library crate -> dependency ranges must not be too narrow or too wide
CLI/app       -> Cargo.lock should pin final dependency versions
workspace     -> related crates should share coherent dependency constraints

This is similar to C projects: a library’s pkg-config constraints should be reasonable, while an application release must know what it finally linked against.

Features are public interface too

After a feature name is published, users may write it in dependency configuration:

[dependencies]
device-core = { version = "1", features = ["serde"] }

That means feature names should not be renamed or deleted casually. Like function names, they become external dependencies.

Good features usually express optional capability:

[features]
default = []
serde = ["dep:serde"]
std = []

Weak features often become global macro switches:

[features]
fast = []
new-mode = []
use-v2 = []

If a feature changes the core meaning of an API, downstream users cannot easily tell what library they are actually using. Features are better for “include this optional capability” than for “make the same API mean something else”.

C ABI libraries must not promise Rust ABI

If the release target is a library called from C, versioning returns to ABI:

#[repr(C)]
pub struct DeviceInfo {
    version: u32,
    flags: u32,
}

#[no_mangle]
pub extern "C" fn device_open(path: *const c_char) -> *mut DeviceHandle {
    // ...
}

The stable surface is:

  • exported symbol names
  • extern "C" function signatures
  • #[repr(C)] type layouts
  • struct size and field order
  • memory ownership rules
  • error codes and error-message retrieval

Do not confuse source-level Rust API compatibility with C ABI compatibility. Ordinary Rust functions, traits, generics, and enums are not a stable C ABI.

A clearer structure is:

device-core -> internal Rust library, evolves as Rust API
device-ffi  -> C ABI wrapper, small stable symbol surface
device-cli  -> CLI tool, stable arguments and output

Then release boundaries and version policy are easier to reason about.

Pre-release checks

For a CLI:

cargo build --release
cargo install --path .
device-tool --help
device-tool --version

For a crate:

cargo test
cargo package
cargo publish --dry-run

For library API:

Is too much marked pub?
Can downstream code handle the error type?
Are features stable?
Do README examples build?
Does the version match the compatibility change?

For a C ABI library:

Does the header match exported symbols?
Are structs repr(C)?
Are allocation and free functions paired?
Are error codes stable?
Does the soname or package version follow the release policy?

Rust reduces the friction around building and publishing, but it does not decide the compatibility promise for you. Once scripts, Cargo.toml, C headers, or dynamic linkers depend on something, that thing is API.