xtask/src/publish.rs
//! `cargo xtask publish` — build the PUBLIC tree and put it on branch `public`.
//!
//! ```text
//! cargo xtask publish build the branch, prove it compiles, push nothing
//! cargo xtask publish --push …and push it to the public remotes
//! ```
//!
//! # ⚠ A KNOWN TWIN — `monetize/xtask/src/publish.rs`
//!
//! MEASURED 2026-09-10 by `nornir mimir analyze duplicates`: monetize's copy is
//! 616 lines against this file's 662, and `diff` is **188 lines** — nearly all
//! of it the two DATA tables (which globs are the design record, which files are
//! product twice over) and the prose that explains them. The MACHINERY either
//! side of those tables — the throwaway worktree, the cleanse walk, the
//! `cargo metadata` + `cargo check` proof that the public branch still compiles,
//! the orphan-branch commit — is the same procedure written twice. gunnar's own
//! `xtask/src/publish.rs` (856 lines) is the third writing.
//!
//! It is NOT unified, and the reason is the rule in `Cargo.toml` one directory
//! up: **this crate has ZERO dependencies, deliberately**, because a workspace
//! member takes part in resolver-2 feature unification and a dependency here
//! could turn a feature on in a crate the BFF links. The natural home is
//! `edda/crates/nornir-xtask-support` (std-only xtask primitives; it took
//! gunnar's `expand_home` in the same sweep), and the parameterization is
//! already visible in the file: the two `const` tables and the crate names
//! handed to `cargo check` are the whole of what a `PublishConfig` would carry.
//!
//! Naming it is the guard until then: the day this file's cleanse learns
//! something monetize's does not, the twin is where to put it too.
//!
//! # What "public" means here
//!
//! This repository holds two kinds of file. One is the product: the two crates,
//! the vendored protos, the manifests, the tests, the browser matrix, the front
//! page, and the RPC coverage manual a test reads. The other is the record of
//! how the product was arrived at — design notes, plans, resumption points, the
//! state of one afternoon's work. The second kind is not secret in the sense of
//! credentials; it is simply not the thing anyone clones this repository to
//! read, and it names dates and internal ids that mean nothing outside the tree.
//!
//! So the public branch is HEAD's tree **minus a declared list**, and the list
//! is [`EXCLUSIONS`] — a const, with one reason per line, rather than a script
//! somebody keeps in their shell history.
//!
//! # Why an orphan commit and not a filtered history
//!
//! Rewriting history means deciding, for every commit that ever touched an
//! excluded path, what that commit becomes. There is no answer that is both
//! cheap and correct, and a wrong answer is published permanently. One orphan
//! commit per release has no such question in it: the tree is exactly what the
//! predicate says, and nothing behind it can leak because there is nothing
//! behind it.
//!
//! # Why it compiles the result before it is believed
//!
//! Excluding a file that a build reads produces a branch that looks right and
//! does not build, and nothing about writing the exclusion list would have said
//! so. [`run`] therefore checks the tree out into a worktree beside this
//! repository — so a sibling checkout resolves exactly as it does here — runs
//! `cargo metadata`, `cargo check -p gunnar-ui-server` and the README version
//! guard in it, and removes the worktree afterwards.
//!
//! # Git is driven through `Command`
//!
//! `xtask` carries **zero dependencies** by construction, so there is no git
//! library available to it. The repository's pure-Rust, nothing-shelled-out-to
//! rule is about the SERVER, which spawns nothing; this is repository tooling a
//! human types.
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
/// Paths that stay out of the public tree, each with the reason it is out.
///
/// # The pattern language
///
/// Deliberately tiny, because a glob library is a dependency and this list is
/// read by humans more often than by the matcher:
///
/// * `dir/**` — the path is inside `dir/`.
/// * `dir/*.ext` — a file directly in `dir/` with that extension.
/// * `*text*` — the FILE NAME contains `text` **and** the file is a `.md` or a
/// `.txt`. Narrow on purpose: a whole-path match with no extension test would
/// eat source files. `*session*` would otherwise take
/// `crates/gunnar-ui-server/src/auth/session.rs`-shaped paths; the public
/// branch would look right and not compile, which is what
/// [`prove_it_builds`] exists to catch.
/// * anything else — an exact path.
///
/// [`KEPT`] is consulted first, so an exception never has to be spelled as a
/// negative pattern.
pub const EXCLUSIONS: &[(&str, &str)] = &[
(
".nornir/**",
"the design record: design notes and work in flight, kept in the private ops repository",
),
(
"docs/**",
"the rendered books of the design record (the RPC coverage manual is kept, below)",
),
("*PLAN*", "working notes: what was going to be done next"),
("*HANDOFF*", "working notes passed between pieces of work"),
("*session*", "working notes tied to one working session"),
(
"*brief*",
"working notes: the instructions a piece of work started from",
),
];
/// The exceptions, checked before [`EXCLUSIONS`].
///
/// `docs/**` is excluded wholesale because rendered books of the design record
/// live there. One file in it is product twice over: `docs/coverage.md` is the
/// manual that says what the console does with each of gunnar's 80 RPCs, and
/// `crates/gunnar-ui-server/tests/contract.rs` READS it — three of its tests
/// parse the table and fail on a row that is missing or stale. Excluding it
/// would publish a tree whose own test suite cannot run.
pub const KEPT: &[(&str, &str)] = &[
(
"docs/coverage.md",
"the RPC coverage manual; `tests/contract.rs` parses it",
),
// `.nornir/**` is excluded wholesale as the design record. This one file in
// it is not a note: it is the EMITTED description of this repository's build
// space, the file every tool that discovers a repository by that path reads
// (the dwarves forge, the build-thing pane, installtest, the bench harness).
// `crates/gunnar-ui-recipe/tests/toml_parity.rs` reads it too and fails when
// it is not the code's emission — so excluding it would publish a tree whose
// own test suite cannot run, which is exactly the `docs/coverage.md` shape.
(
".nornir/build-variants.toml",
"the emitted build description; `gunnar-ui-recipe`'s `toml_parity` test reads it",
),
];
/// Inside a [`KEPT`] directory and still excluded.
///
/// Empty today. It exists because the ordering in [`excluded`] is the whole
/// rule, and the third list is what makes that ordering testable rather than
/// theoretical.
pub const KEPT_BUT_STILL_EXCLUDED: &[(&str, &str)] = &[];
/// Files whose ABSENCE from the public tree would not fail a build, and which
/// something outside the compiler nonetheless reads.
///
/// A missing `README.md` publishes a front page nobody wrote. A missing
/// `.github/wasm-baseline` makes the workflow's bundle ratchet read an empty
/// file and compare against nothing, green, forever — the exact silent
/// non-check that file's own text forbids. A missing
/// `.nornir/build-variants.toml` publishes a repository that is INVISIBLE to
/// every tool that discovers one by that path, while the recipe crate beside it
/// says it is not. None is caught by [`prove_it_builds`], so all are asserted
/// before the branch is written.
pub const MUST_SURVIVE: &[&str] = &[
"README.md",
".github/wasm-baseline",
".nornir/build-variants.toml",
];
/// Does `pat` describe `path`? See [`EXCLUSIONS`] for the forms.
fn matches(pat: &str, path: &str) -> bool {
if let Some(dir) = pat.strip_suffix("/**") {
return path.starts_with(dir) && path.as_bytes().get(dir.len()) == Some(&b'/');
}
if let Some(rest) = pat.strip_prefix('*') {
if let Some(inner) = rest.strip_suffix('*') {
// A working note, not a source file: the name carries the word AND
// the file is prose.
if !(path.ends_with(".md") || path.ends_with(".txt")) {
return false;
}
let name = path.rsplit('/').next().unwrap_or(path);
return name.contains(inner);
}
}
if let Some((dir, ext)) = pat.split_once("/*") {
if !ext.contains('/') {
let Some(tail) = path.strip_prefix(dir) else {
return false;
};
let Some(tail) = tail.strip_prefix('/') else {
return false;
};
return !tail.contains('/') && tail.ends_with(ext);
}
}
pat == path
}
/// `Some(reason)` when `path` does not belong in the public tree.
///
/// The order is the whole rule: an exception wins over the wholesale exclusion
/// it sits inside, and a narrower exclusion inside an exception wins over that.
pub fn excluded(path: &str) -> Option<&'static str> {
for (pat, why) in KEPT_BUT_STILL_EXCLUDED {
if matches(pat, path) {
return Some(why);
}
}
for (pat, _) in KEPT {
if matches(pat, path) {
return None;
}
}
for (pat, why) in EXCLUSIONS {
if matches(pat, path) {
return Some(why);
}
}
None
}
/// One entry of HEAD's tree.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Entry {
pub mode: String,
pub oid: String,
pub path: String,
}
/// What a run did, so the caller can print it rather than the verb printing
/// from three places.
#[derive(Debug, Default)]
pub struct Report {
pub tree: String,
pub commit: String,
pub kept: usize,
/// `(reason, count)`, in the order the reasons appear in the const lists.
pub excluded_by_reason: Vec<(&'static str, usize)>,
pub checked: bool,
pub pushed: bool,
}
impl Report {
pub fn excluded_total(&self) -> usize {
self.excluded_by_reason.iter().map(|(_, n)| n).sum()
}
}
fn git(root: &Path, args: &[&str]) -> Result<String, String> {
let out = Command::new("git")
.current_dir(root)
.args(args)
.output()
.map_err(|e| format!("git {}: {e}", args.join(" ")))?;
if !out.status.success() {
return Err(format!(
"git {} failed: {}",
args.join(" "),
String::from_utf8_lossy(&out.stderr).trim()
));
}
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
}
/// HEAD's tree, flattened, as `(mode, oid, path)`.
///
/// `-z` and the explicit format because a path may contain anything a
/// filesystem allows, and `git`'s default quoting of such a path would have to
/// be undone before it could be fed back in.
pub fn head_entries(root: &Path) -> Result<Vec<Entry>, String> {
let raw = git(
root,
&[
"ls-tree",
"-r",
"-z",
"--format=%(objectmode) %(objectname)\t%(path)",
"HEAD",
],
)?;
let mut out = Vec::new();
for rec in raw.split('\0').filter(|s| !s.is_empty()) {
let (meta, path) = rec
.split_once('\t')
.ok_or_else(|| format!("unparsable ls-tree record: {rec:?}"))?;
let (mode, oid) = meta
.split_once(' ')
.ok_or_else(|| format!("unparsable ls-tree meta: {meta:?}"))?;
out.push(Entry {
mode: mode.to_string(),
oid: oid.to_string(),
path: path.to_string(),
});
}
Ok(out)
}
/// Write `entries` as a tree object, through a temporary index.
///
/// `update-index --index-info` is the one plumbing command that takes a whole
/// index as text, so the public tree is built in one call rather than by
/// removing paths from a copy of HEAD's index one at a time.
fn write_tree(root: &Path, entries: &[Entry]) -> Result<String, String> {
let index = root.join(".git").join("gunnar-ui-publish-index");
let _ = std::fs::remove_file(&index);
let mut child = Command::new("git")
.current_dir(root)
.env("GIT_INDEX_FILE", &index)
.args(["update-index", "-z", "--index-info"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("git update-index: {e}"))?;
{
use std::io::Write as _;
let mut stdin = child.stdin.take().expect("stdin was piped");
for e in entries {
write!(stdin, "{} {} 0\t{}\0", e.mode, e.oid, e.path)
.map_err(|err| format!("feeding git update-index: {err}"))?;
}
}
let out = child
.wait_with_output()
.map_err(|e| format!("git update-index: {e}"))?;
if !out.status.success() {
return Err(format!(
"git update-index failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
));
}
let tree = Command::new("git")
.current_dir(root)
.env("GIT_INDEX_FILE", &index)
.args(["write-tree"])
.output()
.map_err(|e| format!("git write-tree: {e}"))?;
let _ = std::fs::remove_file(&index);
if !tree.status.success() {
return Err(format!(
"git write-tree failed: {}",
String::from_utf8_lossy(&tree.stderr).trim()
));
}
Ok(String::from_utf8_lossy(&tree.stdout).trim().to_string())
}
/// `[workspace.package] version`, read the same way the README guard reads it.
fn version(root: &Path) -> String {
crate::release_version::workspace_version(root)
}
/// Today, as `YYYY-MM-DD`, from `git`'s own clock so the verb has no time
/// dependency of its own.
fn today(root: &Path) -> Result<String, String> {
Ok(git(root, &["log", "-1", "--format=%cd", "--date=short"])?
.trim()
.to_string())
}
/// The gates that must hold before a public branch is written at all.
///
/// Both are about *what is being published*, not about tidiness: a dirty tree
/// means the published commit is not the tree anyone can reproduce, and a HEAD
/// that is not `main` means it is not the branch this repository releases from.
pub fn refuse_reasons(status: &str, branch: &str) -> Vec<String> {
let mut out = Vec::new();
if !status.trim().is_empty() {
out.push(format!(
"the working tree is dirty, so the public tree would not be a tree anyone \
can reproduce from this repository:\n{}",
status.trim()
));
}
if branch.trim() != "main" {
out.push(format!(
"HEAD is on `{}`, and this repository releases from `main`",
branch.trim()
));
}
out
}
/// Build (and optionally push) the public branch.
pub fn run(root: &Path, push: bool) -> Result<Report, String> {
let status = git(root, &["status", "--porcelain"])?;
let branch = git(root, &["rev-parse", "--abbrev-ref", "HEAD"])?;
let refusals = refuse_reasons(&status, &branch);
if !refusals.is_empty() {
return Err(refusals.join("\n"));
}
// **The description that is about to be published must be the code's.**
// This verb writes a tree and pushes it to two public remotes, and
// `.nornir/build-variants.toml` rides along on [`KEPT`]. A stale one is
// worse here than anywhere else: it is read by tools in a checkout that has
// no way back to the person who forgot to re-emit.
gunnar_ui_recipe::require_committed_is_emitted(root).map_err(|e| format!("{e:#}"))?;
let all = head_entries(root)?;
let mut kept = Vec::new();
let mut counts: Vec<(&'static str, usize)> = Vec::new();
for e in all {
match excluded(&e.path) {
None => kept.push(e),
Some(why) => match counts.iter_mut().find(|(w, _)| *w == why) {
Some((_, n)) => *n += 1,
None => counts.push((why, 1)),
},
}
}
for must in MUST_SURVIVE {
if !kept.iter().any(|e| e.path == *must) {
return Err(format!("the public tree has no `{must}`"));
}
}
let tree = write_tree(root, &kept)?;
let msg = format!(
"gunnar-ui {} — public release {}",
version(root),
today(root)?
);
let commit = git(root, &["commit-tree", &tree, "-m", &msg])?
.trim()
.to_string();
git(root, &["update-ref", "refs/heads/public", &commit])?;
let mut report = Report {
tree,
commit,
kept: kept.len(),
excluded_by_reason: counts,
checked: false,
pushed: false,
};
report.checked = prove_it_builds(root)?;
if push {
// Both remotes carry the same branch name and both are pushed with a
// lease: a force that would discard a commit this repository has not
// seen is refused rather than performed.
for remote in ["github-public", "gunnar-rs"] {
git(
root,
&[
"push",
"--force-with-lease",
remote,
"refs/heads/public:refs/heads/main",
],
)?;
}
report.pushed = true;
}
Ok(report)
}
/// Where the throwaway worktree goes: beside the repository, so a sibling
/// checkout resolves from its parent exactly as it does from here.
fn check_dir(root: &Path) -> PathBuf {
root.parent()
.unwrap_or(Path::new("."))
.join("gunnar-ui-public-check")
}
/// **The package the public tree is compiled against**, asked of the recipe.
///
/// The default `server`-role row is what `deploy/deploy.sh` ships and what a
/// reader means by "the console's server"; `check_defaults` proves there is
/// exactly one of it, so this cannot silently pick.
fn default_server_package() -> String {
let set = gunnar_ui_recipe::declared();
set.variants
.iter()
.find(|v| {
v.is_default
&& v.role == Some(gunnar_ui_recipe::nornir_build_thing::plugin::Role::Server)
})
.and_then(|v| v.package.clone())
.expect("the recipe declares exactly one default server row (check_defaults)")
}
/// Check the branch out and compile it.
fn prove_it_builds(root: &Path) -> Result<bool, String> {
let dir = check_dir(root);
let dir_str = dir.to_string_lossy().into_owned();
let _ = git(root, &["worktree", "remove", "--force", &dir_str]);
if dir.exists() {
std::fs::remove_dir_all(&dir).map_err(|e| format!("clearing {dir_str}: {e}"))?;
}
git(root, &["worktree", "add", "--detach", &dir_str, "public"])?;
let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".to_string());
// **The package to check is the recipe's answer, not a string typed here.**
// It used to read `"gunnar-ui-server"`, which was a second place a build
// point of this repository was written down. It is now the package of the
// default `server` row — so a rename, or a second BFF package, changes one
// declaration and this verb follows.
let server = default_server_package();
let steps: Vec<Vec<&str>> = vec![
vec!["metadata", "--format-version", "1"],
vec!["check", "-p", &server],
vec!["test", "-p", "xtask", "--test", "readme_version"],
];
let mut failure = None;
for step in &steps {
eprintln!(" public tree: cargo {}", step.join(" "));
let out = Command::new(&cargo)
.current_dir(&dir)
// ITS OWN target directory, and that is not tidiness.
//
// `CARGO_MANIFEST_DIR` is baked into a test binary at compile time,
// so a `cargo test -p xtask` run from inside this worktree writes a
// binary that believes the workspace root is the worktree. Sharing
// a target directory with the repository hands that binary back to
// the next run in the repository, where every path it reads is a
// directory this verb has already deleted. The proof must not be
// able to break the thing it is proving.
.env("CARGO_TARGET_DIR", dir.join("target"))
.args(step)
.stdout(Stdio::null())
.stderr(Stdio::piped())
.output()
.map_err(|e| format!("cargo {}: {e}", step.join(" ")))?;
if !out.status.success() {
let err = String::from_utf8_lossy(&out.stderr);
failure = Some(format!(
"the public tree fails `cargo {}`:\n{}",
step.join(" "),
diagnosis(&err)
));
break;
}
}
let _ = git(root, &["worktree", "remove", "--force", &dir_str]);
match failure {
Some(e) => Err(e),
None => Ok(true),
}
}
/// The lines worth printing out of a failed cargo run.
///
/// **Never the tail.** A `cargo check` that fails on one crate prints that
/// crate's error and then keeps printing *warnings* from every dependency that
/// compiled after it, so the last thirty lines of a failed run are routinely
/// three warnings about a sibling repository and no mention of what broke. The
/// error lines are what the caller needs, and the tail is what is left when
/// there are none.
fn diagnosis(stderr: &str) -> String {
let mut out: Vec<&str> = Vec::new();
let mut lines = stderr.lines().peekable();
while let Some(l) = lines.next() {
if l.starts_with("error") {
out.push(l);
// The `--> file:line:col` that follows an error names where.
while let Some(next) = lines.peek() {
if next.trim_start().starts_with("-->") {
out.push(lines.next().expect("peeked"));
break;
}
if next.trim().is_empty() || next.starts_with(char::is_whitespace) {
lines.next();
continue;
}
break;
}
}
}
if out.is_empty() {
return stderr
.lines()
.rev()
.take(30)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect::<Vec<_>>()
.join("\n");
}
out.join("\n")
}
#[cfg(test)]
mod tests {
use super::*;
/// The predicate, red first: each of these was chosen because getting it
/// wrong publishes something, or drops something a build needs.
#[test]
fn the_record_is_excluded_and_the_product_is_not() {
for (path, want) in [
(".nornir/role-floor-and-auth-model.md", true),
(".nornir/fancy-graph-design.md", true),
// Product, not record: the emitted build description. See `KEPT`.
(".nornir/build-variants.toml", false),
("docs/gunnar-ui-manual.pdf", true),
("docs/coverage.md", false),
("README.md", false),
("COMMERCIAL-LICENSING.md", false),
("Cargo.toml", false),
("Cargo.lock", false),
(".github/wasm-baseline", false),
(".github/workflows/ci.yml", false),
("crates/gunnar-ui-server/src/serve.rs", false),
("crates/gunnar-ui-server/tests/contract.rs", false),
("crates/gunnar-ui-web/index.html", false),
("crates/gunnar-ui-web/assets/gunnar.webp", false),
("crates/gunnar-ui-server/proto/gunnar/v1/mirror.proto", false),
("crates/gunnar-ui-server/proto/monetize/v1/purchase.proto", false),
("uitest/src/matrix.rs", false),
("xtask/src/publish.rs", false),
] {
assert_eq!(
excluded(path).is_some(),
want,
"{path}: expected excluded={want}, got {:?}",
excluded(path)
);
}
}
/// A `*text*` rule reaches into directories, and stops at the extension.
///
/// The second half is the one that matters: with no extension test,
/// `*session*` would exclude a `session.rs` and the public branch would
/// stop compiling while every other check stayed green.
#[test]
fn a_working_note_is_excluded_but_source_named_like_one_is_not() {
assert!(excluded("WIP-HANDOFF.md").is_some());
assert!(excluded("deploy/PLAN-2026.md").is_some());
assert!(excluded("notes/session-3.md").is_some());
assert!(excluded("docs-src/brief.md").is_some());
assert!(excluded("crates/gunnar-ui-server/src/auth/session.rs").is_none());
assert!(excluded("crates/gunnar-ui-server/tests/session_end_to_end.rs").is_none());
assert!(excluded("crates/gunnar-ui-web/src/pages/plan.rs").is_none());
assert!(excluded("uitest/src/briefing.rs").is_none());
}
/// The exception inside the wholesale exclusion, seen to win.
///
/// `docs/coverage.md` is not decoration: `tests/contract.rs` parses it, so
/// publishing `docs/**` wholesale would ship a tree whose own test suite
/// cannot run.
#[test]
fn the_coverage_manual_survives_the_records_exclusion() {
assert!(excluded("docs/anything-else.md").is_some());
assert!(excluded("docs/coverage.md").is_none());
}
/// The two gates, seen to fire.
#[test]
fn a_dirty_tree_and_a_wrong_branch_are_refused() {
let clean = refuse_reasons("", "main\n");
assert!(clean.is_empty(), "a clean main was refused: {clean:?}");
let dirty = refuse_reasons(" M crates/gunnar-ui-server/src/serve.rs\n", "main\n");
assert_eq!(dirty.len(), 1, "{dirty:?}");
assert!(dirty[0].contains("dirty"), "{dirty:?}");
let branch = refuse_reasons("", "wip/experiment\n");
assert_eq!(branch.len(), 1, "{branch:?}");
assert!(branch[0].contains("wip/experiment"), "{branch:?}");
assert_eq!(refuse_reasons(" M a\n", "wip\n").len(), 2);
}
/// Neither file [`MUST_SURVIVE`] names may be excluded by the list.
///
/// The CI ratchet is the reason the second one is here: an empty
/// `.github/wasm-baseline` makes the workflow compare the built bundle
/// against nothing and pass, which is the silent non-check that file
/// forbids in its own words.
#[test]
fn the_front_page_and_the_bundle_ratchet_are_never_excluded() {
for must in MUST_SURVIVE {
assert!(
excluded(must).is_none(),
"{must} is on MUST_SURVIVE and the exclusion list eats it: {:?}",
excluded(must)
);
}
assert!(MUST_SURVIVE.contains(&".github/wasm-baseline"));
}
/// A failed `cargo check` is reported by its errors, not by whatever
/// happened to be printed last.
#[test]
fn the_diagnosis_is_the_error_and_not_the_trailing_warnings() {
let stderr = "\
error[E0583]: file not found for module `session`
--> crates/gunnar-ui-server/src/lib.rs:89:1
|
89 | mod session;
| ^^^^^^^^^^^^
warning: value assigned to `waited` is never read
--> ../edda/src/resolve.rs:1358:25
warning: `tower-http` (lib) generated 3 warnings
";
let d = diagnosis(stderr);
assert!(d.contains("E0583"), "the error was dropped: {d:?}");
assert!(
d.contains("crates/gunnar-ui-server/src/lib.rs:89:1"),
"the error's location was dropped: {d:?}"
);
assert!(
!d.contains("never read"),
"a trailing warning was reported as the failure: {d:?}"
);
}
/// With no error line at all, the tail is better than nothing.
#[test]
fn a_run_with_no_error_line_falls_back_to_the_tail() {
let d = diagnosis("something went wrong\nand said so oddly\n");
assert!(d.contains("said so oddly"), "{d:?}");
}
/// The sentence `xtask/tests/readme_version.rs` scans must survive into the
/// public tree, because that test runs against it there.
#[test]
fn the_public_front_page_names_the_version() {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.expect("xtask/ has a parent")
.to_path_buf();
let src = std::fs::read_to_string(root.join("README.md")).expect("read README.md");
assert!(excluded("README.md").is_none());
let version = crate::release_version::workspace_version(&root);
assert!(
src.contains(&format!("gunnar-ui {version}")),
"README.md does not name `gunnar-ui {version}`, so the public tree's front \
page names no version"
);
}
}