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
//! ```
//!
//! # What "public" means here
//!
//! This repository holds two kinds of file. One is the product: the crates, the
//! manifests, the tests, the front page. The other is the record of how the
//! product was arrived at — measurement notes, triage documents, deployment
//! facts about one site, the shell scripts a bench was driven with. 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 hosts, 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, runs `cargo metadata`, `cargo check -p gunnar-cli -p
//! gunnar-server` and the README version guard in it, and removes the worktree
//! afterwards.
//!
//! The worktree goes BESIDE this repository, and until 2026-09-09 that placement
//! was load-bearing: the sibling path dependencies (`../edda`, `../znippy`,
//! `../monetize`) had to resolve from it exactly as they do from here. They are
//! crates.io versions now, so the check would pass from anywhere — the placement
//! is kept because it costs nothing and because a proof run in the same shape as
//! the real thing has one fewer difference to argue about.
//!
//! # Git is driven through `Command`
//!
//! `xtask` carries **zero dependencies** by construction, so there is no git
//! library available to it. Shelling to `git` is the existing idiom in this
//! crate — [`crate::trailer_guard`] walks history the same way — and the
//! repository's no-shell rule is about the SERVER, which spawns nothing.
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: the first version of this rule matched the
/// whole path with no extension test, and `*session*` ate
/// `crates/gunnar-lfs/src/session.rs`. The public branch looked right and did
/// not compile, which is precisely what [`prove_it_builds`] exists to catch —
/// and it caught it on the first run.
/// * `prefix-*.suffix` — a root-level file, e.g. `arrow-path-report-*.md`.
/// * 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: measurement notes, triage, one site's deployment facts",
),
(
"docs/**",
"the rendered books of the design record, and their Git-LFS pointers",
),
(
"locked_on_target.md",
"a working note: what was in flight on a given day",
),
(
"BUILD-KEYED-INSTALLER.md",
"a working note about one installer build",
),
(
"arrow-path-report-*.md",
"a dated measurement report; the surviving numbers are in the front page and .nornir/detail.md",
),
(
"ragnar-report-*.md",
"a dated measurement report against a sibling project",
),
(
"brokkr.toml",
"the forge's build declaration: how this tree is built here, not what it is",
),
(
"nornir.toml",
"warehouse configuration for the local documentation tooling",
),
(
"docs.toml",
"documentation-tooling configuration; the rendered output is excluded too",
),
("bench/*.sh", "bench drivers: one operator's box and paths"),
("bench/*.py", "bench table formatters, driven by those scripts"),
(
"bench/ENGINE-MATRIX.md",
"raw per-arm matrix; the summary is in .nornir/detail.md",
),
(
"bench/MOTHER-MATRIX.md",
"raw cross-forge matrix; the summary is in .nornir/detail.md",
),
(
"bench/O-ANALYSIS.md",
"the long complexity derivation behind the front page's table",
),
("release/*.sh", "release drivers: one operator's box and paths"),
(
".githooks/**",
"the hook text is process, not product; no test reads it",
),
("*HANDOFF*", "working notes passed between sessions"),
("*PLAN*", "working notes: what was going to be done next"),
("*session*", "working notes tied to one working session"),
("*brief*", "working notes: the instructions a piece of work started from"),
];
/// The exceptions, checked before [`EXCLUSIONS`].
///
/// `.nornir/**` is excluded wholesale because the design record lives there, but
/// four things in it are product: the front page's SOURCE (the root `README.md`
/// is generated from it and the pair is asserted equal by
/// `xtask/tests/readme_version.rs`), the reference page that front page links,
/// the build-variant declaration a build reads, the operator's quickstart, and
/// the image assets the front page renders.
pub const KEPT: &[(&str, &str)] = &[
(
".nornir/README.md",
"the front page's source; README.md is generated from it",
),
(
".nornir/detail.md",
"the reference page the front page links",
),
(
".nornir/build-variants.toml",
"the build-variant declaration; a build reads it",
),
(".nornir/gunnar-quickstart.md", "the operator's how-to"),
(
".nornir/assets/**",
"images the front page and the books render (except *.py, below)",
),
];
/// **Inside a [`KEPT_BUT_STILL_EXCLUDED`] pattern and shipped anyway**, because
/// a page the public tree carries embeds it.
///
/// The `.nornir/assets/*.svg` rule below is right about the diagrams it was
/// written for — they are drawn from the design record, they carry its section
/// numbers, and nothing public renders them. It was right *because* of a
/// measurement ("zero references from README.md, detail.md or the quick start"),
/// and a measurement can stop being true: `.nornir/gunnar-quickstart.md` is
/// KEPT, it is the page an operator is sent to, and since 2026-09-10 it opens
/// with `gunnar-setup.svg`. Publishing the page without the figure would ship a
/// broken image to everyone who reads it.
///
/// So the carve-out is by exact path rather than by loosening the rule: a new
/// diagram drawn from the record is still excluded the day it appears, and the
/// only way onto this list is for a shipped page to actually embed it — which
/// [`tests::a_shipped_figure_is_published`] checks by reading the page.
pub const SHIPPED_ANYWAY: &[(&str, &str)] = &[
(
".nornir/assets/gunnar-setup.svg",
"the quick start's figure; that page is KEPT and embeds it",
),
(
".nornir/assets/gunnar-setup-1.svg",
"the quick start's \u{a7}1 picture; that page is KEPT and embeds it",
),
(
".nornir/assets/gunnar-setup-2.svg",
"the quick start's \u{a7}2 picture; that page is KEPT and embeds it",
),
(
".nornir/assets/gunnar-setup-3.svg",
"the quick start's \u{a7}3 picture; that page is KEPT and embeds it",
),
(
".nornir/assets/gunnar-setup-4.svg",
"the quick start's \u{a7}4 picture; that page is KEPT and embeds it",
),
(
"docs/gunnar-fast-setup.pdf",
"the quick start burned to PDF; the front page links it on its second line",
),
];
/// Inside a [`KEPT`] directory and still excluded.
///
/// `.nornir/assets/` is kept for its images; it also holds a plotting script,
/// which is a bench driver like every other one in [`EXCLUSIONS`].
pub const KEPT_BUT_STILL_EXCLUDED: &[(&str, &str)] = &[
(
".nornir/assets/*.py",
"a plotting script, not an asset the tree renders",
),
// The diagrams and the bake-off chart are drawn from the design record and
// carry its section numbers and work-in-flight markers; no shipped page
// embeds them (measured 2026-09-08: zero references from README.md,
// detail.md or the quick start). Only the badger renders on the front page.
(
".nornir/assets/*.svg",
"a diagram drawn from the design record; no shipped page embeds it",
),
(
".nornir/assets/*.json",
"the bake-off chart's data; no shipped page embeds it",
),
];
/// Does `pat` describe `path`? See [`EXCLUSIONS`] for the four 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);
}
}
// A bare `prefix-*.md` form: `arrow-path-report-*.md`.
if let Some((head, tail)) = pat.split_once('*') {
if !head.is_empty() && !tail.is_empty() && !pat.contains('/') {
return !path.contains('/') && path.starts_with(head) && path.ends_with(tail);
}
}
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, a narrower exclusion inside an exception wins over that, and
/// a named file a shipped page embeds wins over the narrower exclusion.
pub fn excluded(path: &str) -> Option<&'static str> {
for (pat, _) in SHIPPED_ANYWAY {
if matches(pat, path) {
return None;
}
}
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-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 release 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 `master` 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() != "master" {
out.push(format!(
"HEAD is on `{}`, and this repository releases from `master`",
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"));
}
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)),
},
}
}
// The front page is the one file whose ABSENCE would not fail a build, and
// it is the file the whole exclusion list is most likely to eat: it lives
// under `.nornir/`.
if !kept.iter().any(|e| e.path == ".nornir/README.md") {
return Err(
"the public tree has no `.nornir/README.md`, so the front page has \
no source"
.to_string(),
);
}
if !kept.iter().any(|e| e.path == "README.md") {
return Err("the public tree has no `README.md`".to_string());
}
let tree = write_tree(root, &kept)?;
let msg = format!("gunnar {} — 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 {
git(
root,
&[
"push",
"--force-with-lease",
"github-public",
"refs/heads/public:refs/heads/main",
],
)?;
git(
root,
&["push", "gunnar-rs", "refs/heads/public:refs/heads/master"],
)?;
report.pushed = true;
}
Ok(report)
}
/// Where the throwaway worktree goes: beside the repository. This used to be a
/// requirement (the sibling path dependencies resolved from its parent); since
/// 2026-09-09 it is only a habit — see the module header.
fn check_dir(root: &Path) -> PathBuf {
root.parent()
.unwrap_or(Path::new("."))
.join("gunnar-public-check")
}
/// 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());
let steps: &[&[&str]] = &[
&["metadata", "--format-version", "1"],
&["check", "-p", "gunnar-cli", "-p", "gunnar-server"],
&["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. MEASURED: the run of
// 2026-09-08 left `readme_version` failing in the repository with
// "read …/gunnar-public-check/Cargo.toml: No such file", on a tree
// where nothing was wrong. 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/TOTAL-PROMPT.md", true),
(".nornir/defect-record.md", true),
(".nornir/design.md", true),
(".nornir/README.md", false),
(".nornir/detail.md", false),
(".nornir/build-variants.toml", false),
(".nornir/gunnar-quickstart.md", false),
(".nornir/assets/gunnar.webp", false),
(".nornir/assets/deltarow.py", true),
(".nornir/assets/gunnar-system.svg", true),
(".nornir/assets/gunnar-setup.svg", false),
(".nornir/assets/mashup_forge_bakeoff.json", true),
("bench/x.sh", true),
("bench/engine-matrix-table.py", true),
("bench/README.md", false),
("bench/O-ANALYSIS.md", true),
("bench/src/lib.rs", false),
("crates/gunnar-core/src/lib.rs", false),
("crates/x.rs", false),
("docs/gunnar-manual.pdf", true),
("locked_on_target.md", true),
("arrow-path-report-2026-08-10.md", true),
("ragnar-report-2026-08-10.md", true),
(".githooks/pre-push", true),
("README.md", false),
("Cargo.toml", false),
("deny.toml", false),
("xtask/src/publish.rs", false),
] {
assert_eq!(
excluded(path).is_some(),
want,
"{path}: expected excluded={want}, got {:?}",
excluded(path)
);
}
}
/// **Every image a KEPT page embeds is published, and every carve-out
/// earns its place.**
///
/// Both directions, because both have a failure mode and they are different
/// ones. A page that references an excluded asset ships a broken image to
/// every reader (that is what `.nornir/assets/*.svg` would have done to the
/// quick start's figure). A carve-out that nothing references is a piece of
/// the design record leaking into the public tree, which is the exclusion
/// list's entire reason for existing.
///
/// It reads the pages rather than restating what they contain, so the day
/// the figure is dropped from the guide the carve-out goes red instead of
/// quietly outliving it.
#[test]
fn a_shipped_figure_is_published() {
let root = crate::guard::workspace_root();
// The front page's SOURCE is a kept page too: it is the one that links
// the burned guide.
let pages: Vec<(&str, String)> = [
".nornir/gunnar-quickstart.md",
".nornir/detail.md",
".nornir/README.md",
]
.iter()
.filter_map(|p| std::fs::read_to_string(root.join(p)).ok().map(|t| (*p, t)))
.collect();
assert!(
!pages.is_empty(),
"publish: no KEPT page opened; this test would pass vacuously"
);
// Every `assets/…` reference on a kept page, resolved to a repository
// path. A page in `.nornir/` writes `assets/x.svg` — relative to itself,
// which is what a forge's markdown renderer follows — while the front
// page's SOURCE writes `.nornir/assets/x.webp`, because it is rendered
// to the repository root. Both forms have to end up as one path here or
// the check silently passes over the form it did not think of.
let mut referenced: Vec<(&str, String)> = Vec::new();
for (page, text) in &pages {
let dir = std::path::Path::new(page)
.parent()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_default();
for (start, _) in text.match_indices("assets/") {
let rest = &text[start..];
let end = rest
.find(|c: char| {
c == ')' || c == '"' || c == '\'' || c == '`' || c.is_whitespace()
})
.unwrap_or(rest.len());
let token = &rest[..end];
if !token.ends_with(".svg")
&& !token.ends_with(".webp")
&& !token.ends_with(".png")
&& !token.ends_with(".json")
{
continue;
}
// `.nornir/assets/…` already matched at `assets/`, so look left
// for the prefix rather than resolving it against the page.
let absolute = text[..start].ends_with(".nornir/");
referenced.push((
page,
if absolute {
format!(".nornir/{token}")
} else if dir.is_empty() {
token.to_string()
} else {
format!("{dir}/{token}")
},
));
}
// A burned book the page LINKS is a reference like any image: the
// front page's second line points at `docs/gunnar-fast-setup.pdf`,
// and a public tree without it ships a dead link. `docs/` is written
// from the repository root on every page that names it.
for (start, _) in text.match_indices("docs/") {
let rest = &text[start..];
let end = rest
.find(|c: char| {
c == ')' || c == '"' || c == '\'' || c == '`' || c.is_whitespace()
})
.unwrap_or(rest.len());
let token = &rest[..end];
if token.ends_with(".pdf") {
referenced.push((page, token.to_string()));
}
}
}
// Forward: nothing a kept page embeds may be excluded.
for (page, asset) in &referenced {
assert!(
excluded(asset).is_none(),
"{page} embeds {asset}, which the public tree excludes ({:?}) — the page \
would ship with a broken image. Add it to SHIPPED_ANYWAY.",
excluded(asset)
);
}
// Backward: every carve-out is referenced by one of those pages.
for (path, why) in SHIPPED_ANYWAY {
assert!(
referenced.iter().any(|(_, a)| a == path),
"publish: {path} is carved out of the exclusion list ({why}) and no kept page \
references it. A carve-out nobody renders is the design record leaking into \
the public tree — drop it. Seen: {:?}",
referenced.iter().map(|(_, a)| a).collect::<Vec<_>>()
);
}
}
/// A `*text*` rule reaches into directories, and stops at the extension.
///
/// The second half is the one that was learned: with no extension test,
/// `*session*` excluded `crates/gunnar-lfs/src/session.rs` and the public
/// branch stopped 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-lfs/src/session.rs").is_none());
assert!(excluded("crates/gunnar-lfs/tests/session_end_to_end.rs").is_none());
assert!(excluded("crates/gunnar-core/src/plan.rs").is_none());
assert!(excluded("crates/gunnar-recipe/src/briefing.rs").is_none());
assert!(excluded("crates/x/src/handoff.rs").is_none());
}
/// `dir/*.ext` is one directory deep, never a subtree.
#[test]
fn a_directory_extension_rule_does_not_recurse() {
assert!(excluded("bench/engine-matrix.sh").is_some());
assert!(excluded("bench/examples/forge/mod.rs").is_none());
assert!(excluded("release/run-vm.sh").is_some());
}
/// The two gates, seen to fire.
#[test]
fn a_dirty_tree_and_a_wrong_branch_are_refused() {
let clean = refuse_reasons("", "master\n");
assert!(clean.is_empty(), "a clean master was refused: {clean:?}");
let dirty = refuse_reasons(" M crates/gunnar-core/src/lib.rs\n", "master\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);
}
/// 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-lfs/src/lib.rs:89:1
|
89 | mod session;
| ^^^^^^^^^^^^
warning: value assigned to `waited` is never read
--> ../znippy/src/resolve.rs:1358:25
warning: `znippy-plugin-git` (lib) generated 3 warnings
";
let d = diagnosis(stderr);
assert!(d.contains("E0583"), "the error was dropped: {d:?}");
assert!(
d.contains("crates/gunnar-lfs/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(".nornir/README.md"))
.expect("read .nornir/README.md");
assert!(excluded(".nornir/README.md").is_none());
let version = crate::release_version::workspace_version(&root);
assert!(
src.contains(&format!("gunnar {version}")),
".nornir/README.md does not name `gunnar {version}`, so the public tree's \
front page names no version"
);
}
}