xtask/tests/publishable.rs
//! **Exactly the declared crates in this workspace may reach a public registry.**
//!
//! Until 2026-10-22 that was one crate, gunnar-forge. The owner's order of
//! 2026-10-22 (`cargo install gunnar` installs a startable server) put the
//! `gunnar` crate and its chain on the list: [`THE_CHAIN`] below. Every other
//! member stays `publish = false`, and a crate that joins or leaves the list
//! has to change this file to do it.
//!
//! The intent was written down twice and enforced nowhere.
//! `crates/gunnar-forge/Cargo.toml` spends thirty lines under the heading "WHY
//! THIS CRATE PUBLISHES AND ITS SIBLINGS DO NOT", and the constellation
//! descriptor says `release = false` for the repository — and neither of those
//! is a thing cargo reads. Until 2026-09-11 no manifest in `crates/` carried a
//! `publish` key at all, so `cargo publish -p gunnar-server` typed in the wrong
//! directory would have put the appliance's server half on crates.io. That is
//! not an undoable mistake: a publish can only be yanked, and a yanked crate is
//! still downloadable by anything that names the version.
//!
//! # The answer is cargo's, not a second reading of the manifests
//!
//! `cargo metadata` is what `cargo publish` itself resolves, inheritance and
//! all. A test that re-read the `.toml` files would be a second implementation
//! of `publish.workspace = true`, and the two could disagree in exactly the
//! case that matters — which is the shape of guard this repository keeps
//! finding in other people's code.
//!
//! In `cargo metadata`'s schema `publish` is `null` for "any registry" and `[]`
//! for "none". The list of allowed registries never appears here, because a
//! third state would mean somebody had introduced a private registry and that
//! is a decision this test should notice.
use std::process::Command;
use xtask::json::Json;
/// The `cargo install gunnar` chain: `gunnar` and every workspace crate it can
/// reach through any feature (owner order 2026-10-22). Sorted.
const THE_CHAIN: &[&str] = &[
"gunnar",
// `cargo install gunnar-cli`: the command-line tool, one of the three products
// (GUN-PRODUCTS, 2026-10-30).
"gunnar-cli",
"gunnar-client",
"gunnar-common",
"gunnar-core",
"gunnar-crypt",
"gunnar-grpc",
"gunnar-http",
"gunnar-lfs",
"gunnar-pairing-door",
"gunnar-policy",
"gunnar-proto",
"gunnar-recipe",
"gunnar-repair",
"gunnar-server",
"gunnar-ssh",
"gunnar-store",
"gunnar-wire",
];
/// The one crate that may go to a public registry, and the reason, in one
/// place. `gunnar-ui` is a separate repository and needs it: without a registry
/// copy, building it requires a `gunnar` checkout parked at exactly
/// `../../../gunnar`, which is a build instruction no clone can carry.
const THE_EXCEPTION: &str = "gunnar-forge";
fn workspace_root() -> std::path::PathBuf {
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.expect("xtask/ has a parent")
.to_path_buf()
}
/// `(name, may_publish)` for every workspace member, as cargo resolves it.
fn members() -> Vec<(String, bool)> {
let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".to_string());
let out = Command::new(cargo)
.args(["metadata", "--no-deps", "--format-version", "1"])
.current_dir(workspace_root())
.output()
.expect("run `cargo metadata`");
assert!(
out.status.success(),
"cargo metadata failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let text = String::from_utf8(out.stdout).expect("cargo metadata is utf-8");
let json = Json::parse(&text).expect("cargo metadata is json");
let packages = json.get("packages").expect("metadata has packages").arr();
assert!(
!packages.is_empty(),
"cargo metadata reported no packages, so every assertion below is vacuous"
);
packages
.iter()
.map(|package| {
let name = package
.get("name")
.and_then(Json::str)
.expect("a package has a name")
.to_owned();
// `null` — absent from the object, or present as Null — is "any
// registry". Anything else is a list, and an EMPTY list is the only
// other value this workspace may hold.
let publish = package.get("publish");
let may = match publish {
None | Some(Json::Null) => true,
Some(Json::Arr(allowed)) => {
assert!(
allowed.is_empty(),
"{name} names specific registries ({allowed:?}); this test only knows \
`publish = false` and `publish = true`, and a private registry is a \
decision that should not arrive silently"
);
false
}
Some(other) => panic!("{name} has an unreadable `publish` value: {other:?}"),
};
(name, may)
})
.collect()
}
/// **One crate may publish, by name, and every other member may not.**
///
/// Both directions in one test on purpose. A workspace where nothing could
/// publish would satisfy "gunnar-server may not" perfectly and would also have
/// broken the one release this repository actually makes — and the day somebody
/// silences THAT by flipping the workspace default back, this fails on the
/// other half rather than going quiet.
#[test]
fn only_the_declared_members_may_reach_a_public_registry() {
let members = members();
assert!(
members.len() >= 15,
"cargo metadata reported only {} members; this workspace has more, so the scan is \
not seeing the crates it is about",
members.len()
);
let mut publishable: Vec<&str> = members
.iter()
.filter(|(_, may)| *may)
.map(|(name, _)| name.as_str())
.collect();
publishable.sort_unstable();
let mut expected: Vec<&str> = THE_CHAIN.to_vec();
expected.push(THE_EXCEPTION);
expected.sort_unstable();
assert_eq!(
publishable, expected,
"the set of publishable crates is not exactly the `cargo install gunnar` chain plus \
{THE_EXCEPTION}. A crate that can publish and should not is one `cargo publish -p` \
from being on crates.io permanently; a chain crate that cannot publish breaks \
`cargo install gunnar`"
);
// `gunnar-cli` left this check on 2026-10-30 (GUN-PRODUCTS): it is a product now.
let (_, may) = members
.iter()
.find(|(member, _)| member == "xtask")
.unwrap_or_else(|| panic!("xtask is not a workspace member any more"));
assert!(!may, "xtask may be published to a public registry");
// The two DETACHED workspaces (2026-10-22) are outside `cargo metadata`'s view
// of the root, so their own manifests are read: each must say `publish = false`.
for detached in ["crates/gunnar-front", "crates/gunnar-monetize-guest"] {
let text = std::fs::read_to_string(workspace_root().join(detached).join("Cargo.toml"))
.unwrap_or_else(|e| panic!("{detached}/Cargo.toml: {e}"));
assert!(
text.lines().any(|l| l.trim() == "publish = false"),
"{detached} may be published to a public registry"
);
}
}