Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

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"
    );
}