xtask/tests/publish_order.rs
//! A sibling **dev-only** dependency may not carry a version requirement.
//!
//! # The defect this witnesses
//!
//! `cargo package` resolves every dependency that carries a `version` against
//! the registry, dev-dependencies included, and a path is not a substitute.
//! PROVEN both ways on 2026-08-05 with a throwaway pair of crates:
//!
//! ```text
//! dev-dependency WITH a version error: no matching package named `…` found
//! dev-dependency path-only Packaged 4 files, 1.2KiB
//! ```
//!
//! So a versioned dev-dependency on a sibling means that sibling must already
//! be on crates.io when this crate publishes — an ordering constraint. The
//! problem is *where the constraint is written*: `dwarves workspace-release`
//! computes its publish waves from the **normal** dependency graph and does not
//! read dev edges at all. An edge with no normal dependency behind it is
//! therefore invisible to the thing that decides the order.
//!
//! On 2026-08-05 that was not hypothetical. `gunnar-proto` dev-depended on
//! `gunnar-core` with `workspace = true` (which inherits `version = "0.1.0"`),
//! dwarves put `gunnar-proto` in wave 0 and `gunnar-core` in wave 3, and Gate B
//! refused the whole 17-crate cascade with
//! `no matching package named gunnar-core found`. Nothing before Gate B saw it:
//! `cargo test --workspace` is green either way, and so is `cargo xtask guard`.
//!
//! # Why the fix is to drop the version, not to fix the order
//!
//! An order can be recomputed wrong again by any tool that reads a different
//! graph, and there are two such tools already (dwarves' shipped solver and its
//! constructive cross-check — which AGREED with each other here, at divergence
//! rate 0.0, on an order Gate B then refused). Dropping the version removes the
//! constraint instead of restating it: cargo strips a path-only dev-dependency
//! from the published manifest, so no order can be wrong about it.
//!
//! A dev edge that is ALSO a normal dependency is fine and is allowed here: the
//! normal edge is in the graph dwarves reads, so the order is already forced.
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
fn workspace_root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.expect("xtask/ has a parent")
.to_path_buf()
}
/// Every workspace member's directory: `crates/*` plus `xtask`.
fn member_dirs(root: &Path) -> Vec<PathBuf> {
let mut out = Vec::new();
for entry in std::fs::read_dir(root.join("crates")).expect("read crates/") {
let path = entry.expect("dir entry").path();
if path.join("Cargo.toml").is_file() {
out.push(path);
}
}
// **`xtask` is a workspace member too**, and since 2026-08-10 it is the only
// one that is `publish = false`. It was outside this scan because every
// unpublishable crate used to live under `crates/`; when the last of those
// was deleted, `the_publishable_probe_can_say_both_yes_and_no` lost its
// negative sample and has been red ever since — a probe that cannot say
// "no" reporting that it cannot say "no".
//
// `bench/` and `installtest/` are `publish = false` as well and are
// deliberately **not** here: they are detached workspaces (their own
// `[workspace]` tables), so they are not members of this one and the
// publish order does not consider them.
if root.join("xtask/Cargo.toml").is_file() {
out.push(root.join("xtask"));
}
out.sort();
out
}
/// The names of this workspace's own crates — the only ones the order rule is
/// about, since only they are published by the same cascade.
///
/// Read off `member_dirs`, which is where "me" already comes from, so the two can
/// never disagree. It was `name.starts_with("gunnar-")` until 2026-09-09, and that
/// spelling became WRONG the day `gunnar-pack`, `gunnar-pktline` and
/// `gunnar-sendpack` moved to `../edda` and onto crates.io: they still carry the
/// prefix and they are no longer siblings, so a `version` on a dev-edge to one of
/// them constrains nothing — the version is on the registry, and `cargo package`
/// resolves it there like any other third-party crate. Naming the MEMBERS is also
/// strictly wider: a member that does not start with `gunnar-` was invisible to the
/// old spelling and is caught by this one.
fn member_names(root: &Path) -> BTreeSet<String> {
member_dirs(root)
.iter()
.filter_map(|d| d.file_name().map(|n| n.to_string_lossy().into_owned()))
.collect()
}
/// `(normal, dev-carrying-a-version)` sibling dependencies named in a manifest.
/// `members` is the set a name must be in to count as a sibling at all.
///
/// Deliberately textual rather than `cargo metadata`: this must read what the
/// manifest *says*, including `workspace = true`, and must not need a resolve —
/// which is the step that fails outright on a machine with no sibling znippy
/// checkout.
fn sibling_deps(
manifest: &Path,
members: &BTreeSet<String>,
) -> (BTreeSet<String>, BTreeMap<String, String>) {
let text = std::fs::read_to_string(manifest).expect("read a manifest");
let mut normal = BTreeSet::new();
let mut dev = BTreeMap::new();
let mut section = String::new();
for line in text.lines() {
let trimmed = line.trim();
if trimmed.starts_with('[') {
section = trimmed.trim_matches(['[', ']']).to_string();
continue;
}
if trimmed.starts_with('#') || trimmed.is_empty() {
continue;
}
let Some((lhs, rhs)) = trimmed.split_once('=') else {
continue;
};
// `gunnar-store.workspace = true` and `gunnar-store = { … }` both.
let name = lhs.trim().split('.').next().unwrap_or("").trim();
// Only a crate this workspace PUBLISHES can be an ordering constraint. A
// registry crate — `serde`, and since 2026-09-09 `gunnar-pack` and its edda
// siblings — is already on the index when this repo publishes anything.
if !members.contains(name) {
continue;
}
// A version comes either from an explicit `version =` or by inheriting
// the [workspace.dependencies] entry, which carries one.
let inherits = lhs.contains(".workspace") || rhs.contains("workspace = true");
let explicit = rhs.contains("version");
match section.as_str() {
"dependencies" | "build-dependencies" => {
normal.insert(name.to_string());
}
"dev-dependencies" if inherits || explicit => {
let how = if explicit {
"an explicit `version`"
} else {
"`workspace = true`, which inherits `version` from [workspace.dependencies]"
};
dev.insert(name.to_string(), how.to_string());
}
_ => {}
}
}
(normal, dev)
}
#[test]
fn no_sibling_dev_only_dependency_carries_a_version() {
let root = workspace_root();
let members = member_names(&root);
let mut offences = Vec::new();
for dir in member_dirs(&root) {
let (normal, dev) = sibling_deps(&dir.join("Cargo.toml"), &members);
let me = dir
.file_name()
.expect("a crate directory")
.to_string_lossy();
for (name, how) in &dev {
if normal.contains(name) {
// A normal dependency already forces the publish order.
continue;
}
offences.push(format!(
" {me} dev-depends on {name} with {how}, and has no normal dependency on it.\n \
Nothing forces {name} to publish first, and `dwarves workspace-release` reads \
only normal dependencies, so its wave order cannot account for this edge.\n \
Fix: `{name} = {{ path = \"../{name}\" }}` (keep any `features`, drop the version)."
));
}
}
assert!(
offences.is_empty(),
"a sibling dev-only dependency carries a version requirement, which makes this crate \
publishable only in an order no manifest states:\n{}\n\nSee this file's header: \
`cargo package` resolves a versioned dev-dependency against the registry, and Gate B \
refused the whole cascade over exactly this on 2026-08-05.",
offences.join("\n")
);
}
/// The guard above passes vacuously if the parser never finds a dev edge at
/// all — the failure mode a textual parser actually has: one manifest style it
/// does not recognise and it reports clean for ever.
///
/// So the edges that are deliberately ALLOWED (dev + normal on the same
/// sibling) must still be *seen*. If this drops to zero, the parser has gone
/// blind and the assertion above is worthless.
#[test]
fn the_parser_still_sees_the_dev_edges_it_is_allowed_to_pass() {
let root = workspace_root();
let members = member_names(&root);
let mut seen = 0usize;
let mut both = Vec::new();
for dir in member_dirs(&root) {
let (normal, dev) = sibling_deps(&dir.join("Cargo.toml"), &members);
seen += dev.len();
for name in dev.keys() {
if normal.contains(name) {
both.push(format!(
"{}->{name}",
dir.file_name().expect("a crate dir").to_string_lossy()
));
}
}
}
assert!(
seen > 0,
"the manifest parser found no versioned sibling dev-dependency anywhere, so \
`no_sibling_dev_only_dependency_carries_a_version` cannot fail and is not a guard"
);
assert!(
!both.is_empty(),
"the parser found versioned dev edges but none that are also normal dependencies, so the \
allow-branch of the guard is never taken and is untested. Expected \
gunnar-wire->gunnar-store; got {both:?}. (gunnar-client->gunnar-pack and \
gunnar-lfs->gunnar-pktline were named here until 2026-09-09 and are gone on \
purpose: those crates are on crates.io now, so they are not this workspace's \
siblings and cannot constrain its publish order.)"
);
}
// ── the second half of the same rule: a publishable crate may not depend on an
// unpublishable one ────────────────────────────────────────────────────────
//
// The dev-edge guard above is about ORDER. This one is about POSSIBILITY, and it
// is the defect that actually blocked the 2026-08-05 release: `gunnar-cli`
// carried
//
// the old znippy engine crate = { version = "0.1.0", path = "../the old znippy engine crate", optional = true }
//
// and the old znippy engine crate is `publish = false`. Cargo refuses to publish a crate
// naming a dependency it cannot resolve from the registry, and dropping the
// `version` to keep only the path does NOT help — PROVEN 2026-08-05 with a
// throwaway pair of crates, optional dependency and feature off:
//
// normal dep, path-only error: all dependencies must have a version
// requirement specified when packaging
// dev-dep, path-only Packaged 4 files, 1.2KiB (and stripped)
//
// So there is no spelling of the edge that publishes. It has to not exist, which
// is why the znippy engine lives in the old znippy engine crate (itself `publish = false`,
// and allowed to name whatever it likes).
//
// 2026-08-10: the second such crate, `crates/gunnar-znippy`, is DELETED. It held
// the storage engine, which is now a *feature* of `gunnar-server`
// (`engine-znippy`) rather than a crate of its own — see `locked_on_target.md`
// `gunnar-server` is `publish = false`-free and names `znippy-plugin-git`
// by path, so the edge this file is about now exists on a PUBLISHABLE crate and
// is a path dependency into another repository rather than into a `publish =
// false` sibling. `no_publishable_crate_depends_on_an_unpublishable_one` below
// scans `crates/` only, so it does not see that edge at all: it is the release
// gate's problem, not this guard's, and it is recorded here so the gap is
// declared rather than discovered at Gate B again.
//
// Nothing else in the tree could see this. `cargo test --workspace` is green
// either way, `cargo xtask guard` is green either way, and the dependency was
// *optional*, so no default build even linked it. It surfaced as a Gate-B
// refusal at release time, which is the most expensive place to learn it.
/// Is this crate itself publishable?
///
/// Textual, like everything else here, and for the same reason: `cargo
/// metadata` needs a resolve, and resolving this workspace is exactly what
/// fails on a machine with no sibling znippy checkout for the detached
/// crates — the machines this guard most needs to work on.
fn publishable(manifest: &Path) -> bool {
let Ok(text) = std::fs::read_to_string(manifest) else {
// A dependency naming a directory with no manifest is a different
// defect, and cargo says so long before this test runs. Treating it as
// publishable here keeps this guard about the one thing it is about.
return true;
};
!text
.lines()
.map(str::trim)
.any(|l| l.starts_with("publish") && l.contains("false"))
}
#[test]
fn no_publishable_crate_depends_on_an_unpublishable_one() {
let root = workspace_root();
let members = member_names(&root);
let mut offences = Vec::new();
for dir in member_dirs(&root) {
let manifest = dir.join("Cargo.toml");
if !publishable(&manifest) {
// An unpublishable crate may name anything: nothing will ever try
// to upload it. the old znippy engine crate is the only one left — see the
// header for where `gunnar-znippy` went.
continue;
}
let me = dir
.file_name()
.expect("a crate directory")
.to_string_lossy();
let (normal, _dev) = sibling_deps(&manifest, &members);
for name in &normal {
let theirs = root.join("crates").join(name).join("Cargo.toml");
if !publishable(&theirs) {
offences.push(format!(
" {me} depends on {name}, which is `publish = false`.\n \
`cargo publish -p {me}` cannot resolve it from the registry, and dropping \
the `version` to leave only the `path` is refused too (`all dependencies \
must have a version requirement specified when packaging`).\n \
Fix: the edge has to go — move whatever needs {name} into a crate that is \
itself `publish = false`, as the old znippy engine crate is."
));
}
}
}
assert!(
offences.is_empty(),
"a publishable crate names an unpublishable dependency, so the release cannot be \
published in any order:\n{}",
offences.join("\n")
);
}
/// The guard above passes vacuously if `publishable` cannot recognise
/// `publish = false` — and then it would report clean over a tree full of the
/// defect. Both answers are asserted against real manifests in this repository,
/// so a parser that returned `true` (or `false`) for everything fails here.
#[test]
fn the_publishable_probe_can_say_both_yes_and_no() {
let root = workspace_root();
let unpublishable: Vec<String> = member_dirs(&root)
.into_iter()
.filter(|d| !publishable(&d.join("Cargo.toml")))
.map(|d| d.file_name().expect("a crate dir").to_string_lossy().into())
.collect();
// NO crate under crates/ is `publish = false` today, and that is
// deliberate: the last one, `gunnar-build-thing`, became publishable on
// purpose (its own manifest: "It is publishable, and that is load-bearing"
// — wave 0 of `dwarves workspace-release`, because a publishable
// `gunnar-cli` cannot name an unpublishable build-dependency). The
// negative sample this probe used to take from a live manifest (first
// `gunnar-znippy`, deleted 2026-08-10; then a literal the 2026-08-16
// comment sweep had garbled into prose) is therefore `xtask`, the one
// workspace member outside `crates/` — see [`member_dirs`], which scans it
// for exactly this reason — and, below, a SYNTHETIC manifest: the probe
// must still be seen answering "no" even if that last live sample ever
// becomes publishable, or the guard above it is vacuous over a tree full
// of the defect.
//
// The rule is not "exactly one": it is **every unpublishable member is a
// tool that ships in no release, and is named here by hand**. Since
// 2026-09-16 there are three — `xtask`; `gunnar-growbench` (the KVM
// growth/twin bench harness under `crates/`, `publish = false` in its own
// manifest: a binary that drives appliances, never a library anything
// publishable may name); and `gunnar-loadbench`, which measures the live
// estate under concurrency and is the same kind of thing for the same
// reason — it holds thresholds and vantages that mean nothing off this
// estate, and a published crate able to name it would carry them to
// strangers. Each name is a decision, so the set is spelled
// out rather than counted: a THIRD `publish = false` crate, or one of these
// becoming publishable, still turns this red and asks for the same
// decision — and `no_publishable_crate_depends_on_an_unpublishable_one`
// above is what proves nothing published reaches either of them.
//
// Since 2026-10-22 two more say `publish = false` in their own manifest:
// `gunnar-front` and `gunnar-monetize-guest`, DETACHED from the root workspace
// (a lone clone must load it) and so no longer inheriting the root's `publish`.
// They ship on the estate's media, never on a registry: both link crates that
// only sibling checkouts have.
const TOOLS_THAT_SHIP_IN_NO_RELEASE: &[&str] = &[
"gunnar-front",
"gunnar-growbench",
"gunnar-loadbench",
"gunnar-monetize-guest",
// GUN-PERF (2026-10-31): the end-to-end bench with the real clients, detached;
// it drives a release `gunnar` and ships in no release, like the two benches above.
"gunnar-perfbench",
"xtask",
];
assert_eq!(
unpublishable,
TOOLS_THAT_SHIP_IN_NO_RELEASE
.iter()
.map(|s| s.to_string())
.collect::<Vec<_>>(),
"the `publish = false` workspace members are exactly the tools named here, and they are \
the negative samples this probe takes from live manifests; a new `publish = false` crate \
widens the set `no_publishable_crate_depends_on_an_unpublishable_one` must police, so \
it is named here on purpose. got {unpublishable:?}"
);
assert!(
unpublishable.iter().any(|n| n == "xtask"),
"the probe must still be seen answering \"no\" on a live manifest"
);
// xtask has ZERO dependencies — no `tempfile` here; the scratch is the
// same temp_dir()+pid Drop shape `bench_guard.rs` uses.
struct Scratch(std::path::PathBuf);
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
let scratch =
Scratch(std::env::temp_dir().join(format!("gunnar-publish-probe-{}", std::process::id())));
std::fs::create_dir_all(&scratch.0).expect("a scratch dir for the synthetic manifest");
let synthetic = scratch.0.join("Cargo.toml");
std::fs::write(
&synthetic,
"[package]\nname = \"probe-fixture\"\nversion = \"0.0.0\"\npublish = false\n",
)
.expect("write the synthetic manifest");
assert!(
!publishable(&synthetic),
"the probe read a manifest that says `publish = false` and called it publishable — \
the guard above it is blind"
);
// And it must not answer "unpublishable" to everything: the crates the
// release actually ships are publishable, and `gunnar-cli` is the one this
// whole file is about.
assert!(publishable(&root.join("crates/gunnar-cli/Cargo.toml")));
assert!(publishable(&root.join("crates/gunnar-store/Cargo.toml")));
let publishable_count = member_dirs(&root)
.into_iter()
.filter(|d| publishable(&d.join("Cargo.toml")))
.count();
// The floor was `16` and had been RED before this file was touched — a
// stale count, not a defect it caught. `crates/` held sixteen directories
// and two of them were `publish = false`, so the true answer was fourteen
// and this assertion could not pass on any tree. It went stale when the six
// leaf crates (`gunnar-pack`, `gunnar-delta`, `gunnar-bitmap`,
// `gunnar-pktline`, `gunnar-sendpack`, `gunnar-stage`) were donated to edda
// and nobody re-counted.
//
// It is a FLOOR and not an equality on purpose: this half of the probe is
// only asserting that `publishable` does not answer "no" to everything, so
// a crate added must not make it fail. The exact set is the other half's
// job (`assert_eq!(unpublishable, vec!["xtask"])` above), and that one is
// exact.
assert!(
publishable_count >= 12,
"only {publishable_count} workspace member(s) read as publishable, which is fewer \
than the release ships; the probe has gone blind in the other direction"
);
}