Publishing crate versions to a private registry is something I do pretty frequently. I have a certain setup, tooling and workflow I have built-up over time that works pretty well.
Two tools are at the center of everything: cargo
(duh) and just
.
just
("just a command runner") works similarly to make
, but without the weird syntax of Makefiles (i.e. no "phony" targets). It doesn't have build-system functionality (i.e. it doesn't perform logic on which things need to be compiled based on file metadata), but as a result it's much nicer to use for defining repeatable commands.
cargo
is, of course, the Rust build tool, and the cargo publish
subcommand is used to publish crates to a registry:
$ cargo publish --help
Upload a package to the registry
Usage: cargo publish [OPTIONS]
Options:
-q, --quiet Do not print cargo log messages
--index <INDEX> Registry index URL to upload the package to
--token <TOKEN> Token to use when uploading
--no-verify Don't verify the contents by building them
-v, --verbose... Use verbose output (-vv very verbose/build.rs output)
--allow-dirty Allow dirty working directories to be packaged
--color <WHEN> Coloring: auto, always, never
--target <TRIPLE> Build for the target triple
--frozen Require Cargo.lock and cache are up to date
--target-dir <DIRECTORY> Directory for all generated artifacts
--locked Require Cargo.lock is up to date
-p, --package [<SPEC>] Package to publish
--manifest-path <PATH> Path to Cargo.toml
--offline Run without accessing the network
--config <KEY=VALUE> Override a configuration value
-F, --features <FEATURES> Space or comma separated list of features to activate
--all-features Activate all available features
-Z <FLAG> Unstable (nightly-only) flags to Cargo, see 'cargo -Z help' for details
--no-default-features Do not activate the `default` feature
-j, --jobs <N> Number of parallel jobs, defaults to # of CPUs
--keep-going Do not abort the build as soon as there is an error (unstable)
--dry-run Perform all checks without uploading
--registry <REGISTRY> Registry to publish to
-h, --help Print help information
Run `cargo help publish` for more detailed information.
Generally speaking, it's a good idea to perform some pre-flight checks before publishing a new crate version, so that you don't accidentally publish something in error. For instance, if there's a typo in the last small code change, and as a result, the crate no longer builds.
Variadic Command Parameters
First, let me explain a pattern I use in defining commands in a justfile
.
Often, it's convenient to specify environment variables and other context in a justfile
, so every command includes that environment context automatically.
This, in turn, makes it advantageous to execute commands via just
, vs. executing the "bare" commands in a terminal. However, it's also necessary to be able to run unexpected, ad-hoc commands without incident. One possible problem, for example, is that cargo
will re-compile everything whenever it is invoked with a different environment context, vs. performing its normal incremental build. If you had been invoking commands via just
and then you use cargo
directly in a terminal, the change in environment will trigger a full rebuild, which is annoying and time-consuming.
Variadic parameters are a useful tool for building flexible "wrapper" commands that allow you to execute common commands, including unexpected, ad-hoc commands, using just
.
For example, consider this justfile
:
# ~/src/my-rust-crate/justfile set dotenv-load := true export RUSTFLAGS := "-C target-cpu=native" rustc-version := "nightly" cargo +args='': cargo +{{rustc-version}} {{args}}
The +args=''
is a variadic parameter, allowing any extra arguments to be passed at runtime, making it very flexible. E.g.:
$ just cargo check --example my-example
...is equivalent to running the command...
$ RUSTFLAGS='-C target-cpu=native' cargo +nightly check --example my-example
And you can combine several layers of these together:
# ~/src/my-rust-crate/justfile set dotenv-load := true export RUSTFLAGS := "-C target-cpu=native" rustc-version := "nightly" cargo +args='': cargo +{{rustc-version}} {{args}} check +args='': @just cargo check {{args}} debug-build binary-name +args='': @just cargo build --bin {{binary-name}} {{args}} release-build binary-name +args='': @just cargo build --bin {{binary-name}} --release {{args}} prod-build binary-name +args='': @just release-build {{binary-name}} {{args}} --features prod
A few other miscellaneous notes:
- I have an
alias j="just"
in my~/.bashrc
so I can typej <cmd>
(fewer letters == less typing == less time) set dotenv-load := true
is now required (didn't used to be) forjust
to load the contents of.env
at runtime- once in a while I run into issues where I need to re-order or tweak how arguments and parameters are included in a command to prevent syntax errors in the post-interpolation rendering of it. For example, it may be necessary to use
--features=prod
instead of--features prod
so the option is treated a single argument/string, preventing anything else from being inserted in between the--features
andprod
parts. Another example is changing the location of{{args}}
in the command
Pre-Flight Checks
Now that we've got our just
patterns down, here's how I apply them to the specific task of publishing a new crate version.
1. Pre-Release
pre-release: just cargo check \ && just cargo test \ && just cargo clippy \ && echo "awesome job!"
This checks that
- it builds
- the tests pass
clippy
isn't too angry (errors will trigger an error exit code, warnings won't)
Notes:
- You probably want to add
&& just cargo fmt
to that list (for my own projects, no thanks, my code formatting is art tyvm) - This could be a place where you check that builds work with several different configurations. For example run it with
--features prod
and--features dev
or whatever.
2. Clean Working Directory: No Unstaged Changes
# somewhat hacky. works because the list of unstaged changes would be longer than 1 verify-clean-git: test "$(echo `git status --porcelain` | wc -c)" -eq "1"
3. No Git Tag for Version Number Already Exists
I like to tag releases in git
, which the publish
command will cover.
This check also helps ensure that the version has been updated since the last time the crate was published. Otherwise, the tag would already exist.
A helper command to parse the version from Cargo.toml
is included (get-crate-version
).
get-crate-version: @cat Cargo.toml | rg '^version =' | sed -e 's/^version\s*=\s*//' | tr -d '"' verify-release-tag-does-not-exist: VERSION=$(just get-crate-version) \ && test -z "$(git tag | rg \"v${VERSION}\")" # Error: tag appears to exist already
4. Prepare Sqlx Offline Mode (Optional)
This only applies if you are using the sqlx
crate as a dependency. This command is used to update the sqlx-data.json
file that is used to run tests in environments where DATABASE_URL
environment variable is not present (or SQLX_OFFLINE=true
).
set dotenv-load := true # note: DATABASE_URL (needed by sqlx command) defined in .env # run sqlx prepare prep-sqlx-offline: cargo install sqlx-cli cargo +{{rustc-version}} sqlx prepare -- --features prod --lib # run sqlx prepare and then commit changes if there are any update-sqlx-offline-and-commit: just prep-sqlx-offline just verify-clean-git \ || ( sleep 0.25 && git add sqlx-data.json && git commit -m 'update sqlx-data.json') just verify-clean-git
Notes:
- The reason we always run
cargo install sqlx-cli
in this command is because the version ofsqlx-cli
needs to match the version ofsqlx
used by your crate. Whensqlx
is updated, the previoussqlx-cli prepare
output (i.e.sqlx-data.json
) will no longer be compatible. If the current version installed is the latest, this is a noop - You may notice that the
prep-sqlx-offline
command did not utilize thejust cargo {{args}}
convention: the reason is that it needed to pass args tocargo
after the command args, using the--
separator (i.e.cargo sqlx prepare <sqlx args> -- <cargo args>
), so it wouldn't work here. That kind of thing happens, but it might be a tricky error when encountered
The Final Command
Putting it all together:
# ~/src/my-rust-crate/justfile rustc-version := "nightly" publish-registry := "shipyard-rs" publish-features := "example-a example-b" publish +args='': verify-clean-git verify-release-tag-does-not-exist pre-release just update-sqlx-offline-and-commit git push sleep 0.25 cargo +{{rustc-version}} publish \ --registry {{publish-registry}} \ --features {{publish-features}} \ --no-default-features {{args}} git tag "v$(just get-crate-version)" git push --tags rm -rf target/package
Notes:
- the commands listed to the right of the colon on the same line as
publish
are dependencies, similar to how aMakefile
works, and will be run prior to execution ofpublish
(and must complete successfully) - to ensure it runs after the pre-flight checks are completed (the dependency commands), it was necessary to put
just update-sqlx-offline-and-commit
in the body of the command definition, vs. as a dependent command itself - I added
sleep 0.25
when getting it to work the first time, and now I can't remember why. But it's included here as example of possible mitigation tool when confronted with asynchronous, non-deterministic issues that crop up - the
git tag
comes at the very end of the process, so that there's nothing else that could go wrong, since it's a bit annoying to need to delete it after the fact rm -rf target/package
: as best I can tell, there is no advantage in terms of future incremental compilation to saving the contents oftarget/package
directory generated bycargo publish
, so I always delete it to save the disk space- publishing to the private registry requires additional configuration in
~/.cargo/config.toml
and~/.cargo/credentials
(for supplying the auth token), consult docs as needed
Self-Contained Example Justfile
set dotenv-load := true rustc-version := "nightly" publish-registry := "shipyard-rs" publish-features := "prod" export RUSTFLAGS := "-C link-arg=-fuse-ld=lld -C target-cpu=native" # cargo [args].. (with env set) cargo +args='': cargo +{{rustc-version}} {{args}} # checks no unstaged changes verify-clean-git: test "$(echo `git status --porcelain` | wc -c)" -eq "1" # returns current version in Cargo.toml get-crate-version: @cat Cargo.toml | rg '^version =' | sed -e 's/^version\s*=\s*//' | tr -d '"' # ensures v{version} tag does not already exist verify-release-tag-does-not-exist: VERSION=$(just get-crate-version) \ && test -z "$(git tag | rg \"v${VERSION}\")" # Error: tag appears to exist already # run sqlx prepare prep-sqlx-offline: cargo install sqlx-cli cargo +{{rustc-version}} sqlx prepare -- --features {{publish-features}} --lib # run sqlx prepare. if there are changes from that, commit them. update-sqlx-offline-and-commit: just prep-sqlx-offline just verify-clean-git \ || ( sleep 0.25 && git add sqlx-data.json && git commit -m 'update sqlx-data.json') just verify-clean-git # ensure crate builds, tests pass, and clippy is not angry pre-release: just cargo check \ && just cargo test \ && just cargo clippy \ && echo "awesome job!" # publish crate version to private registry publish +args='': verify-clean-git verify-release-tag-does-not-exist pre-release just update-sqlx-offline-and-commit git push sleep 0.25 cargo +{{rustc-version}} publish \ --registry {{publish-registry}} \ --features {{publish-features}} \ --no-default-features {{args}} echo "adding git tag, now that EVERYTHING worked..." git tag "v$(just get-crate-version)" git push --tags rm -rf target/package