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

nordisk/gunnar

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