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

nordisk/facett

facett-ops/src/pip.rs

//! **`cargo ops publish-pip`** — build the two PyPI wheels, `facett` (the components)
//! and `facett-demo` (the Streamlit app), **inside a container**, then verify the
//! produced BYTES back before anyone can publish them.
//!
//! The owner, 2026-09-05: *"make build environment so it can build pip packages, ready
//! for release, use a CONTAINER for the horrible python language"*. So: no python on
//! the host, ever. The host side of this file is pure Rust — it stages a build
//! context, drives podman through [`draupnir::container`] (the same REST-over-bollard
//! door knut's SparkServer and dwarves' runa backend already take), copies the
//! artifacts out, and reads them back. There is no `Command::new("podman")` and no
//! shell script anywhere in the path.
//!
//! ## The two halves, and why the second one is the point
//!
//! Building a wheel is easy and proves nothing: `python -m build` exits 0 on a
//! package whose data files were never copied in, and the result installs fine and
//! renders nothing. That is exactly the failure this lane exists to prevent, so the
//! verb does not trust its own build. [`verify_wheel`] re-opens the produced `.whl`
//! as a plain zip and checks, against the wheel's own `RECORD`:
//!
//! - every recorded file is present, and its sha256 and size MATCH the record;
//! - every file in the archive is recorded (an unrecorded file is one `pip` will
//!   install and never uninstall);
//! - all four shipped assets exist, are NON-EMPTY, and the wasm actually begins with
//!   the `\0asm` magic.
//!
//! That last clause is [LAW 2] applied to bytes: a zero-byte `facet_wrapped_bg.wasm`
//! is present, is recorded, hashes correctly, and is not a component. "The file is
//! there" is the identity value of this check; the magic number is not.
//!
//! [LAW 2]: never accept a green you have not seen red — `tests/wheel_verify.rs`
//! constructs a wheel for each fault below and asserts THAT SPECIFIC fault comes back.

use std::collections::BTreeMap;
use std::io::Read;
use std::path::{Path, PathBuf};

use base64::Engine as _;
use facett_build_thing::nornir_build_thing;
use sha2::{Digest, Sha256};

/// The image tag the wheel is built in — the recipe's `[[build.container]]` name,
/// not a second spelling of it. A tag is a convenience; the caller records the
/// image ID the reproducible build actually returned, and that is the pin.
pub use facett_build_thing::PIP_TOOLCHAIN_IMAGE;

/// The recipe, compiled INTO the xtask binary. Shipping it as an `include_str!`
/// rather than a path read means the verb cannot be run against a Containerfile that
/// is not the one in this commit.
pub const PIP_TOOLCHAIN_CONTAINERFILE: &str =
    include_str!("containerfiles/pip-toolchain.Containerfile");

/// The import package inside the wheel — the recipe's `distribution`, which is the
/// spelling the wheel's own metadata and the import use (PEP 503 folds it to
/// the project name for the URL; `holger::normalize_name` is that fold).
pub use facett_build_thing::DISTRIBUTION as IMPORT_PACKAGE;

/// **Which of the two PyPI releases** (owner decision 2026-10-02). Every name, path and
/// cell is read off the recipe — this enum only says WHICH row.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Release {
    /// `facett` — the components: the wasm bundles, the loader, `facett.streamlit`.
    Components,
    /// `facett-demo` — the pure-Python Streamlit app (depends on `facett[streamlit]`).
    Demo,
}

impl Release {
    /// Both, in build order (the demo depends on the components at install time only,
    /// but a release of the demo without the components it pins is not installable).
    pub const ALL: [Release; 2] = [Release::Components, Release::Demo];

    /// The recipe's `kind = "package"` row.
    #[must_use]
    pub fn variant(self) -> &'static str {
        match self {
            Release::Components => facett_build_thing::VARIANT,
            Release::Demo => facett_build_thing::DEMO_PIP_VARIANT,
        }
    }

    /// The PyPI project name a user types.
    #[must_use]
    pub fn project(self) -> &'static str {
        match self {
            Release::Components => facett_build_thing::PYPI_PROJECT,
            Release::Demo => facett_build_thing::DEMO_PYPI_PROJECT,
        }
    }

    /// The import package (also the wheel filename's distribution field).
    #[must_use]
    pub fn import_package(self) -> &'static str {
        match self {
            Release::Components => facett_build_thing::DISTRIBUTION,
            Release::Demo => facett_build_thing::DEMO_DISTRIBUTION,
        }
    }

    /// The declared `[[build.package]]` cell.
    pub fn cell(self) -> Result<nornir_build_thing::package::PackageBuild, String> {
        facett_build_thing::declared()
            .package(self.variant())
            .cloned()
            .ok_or_else(|| format!("facett-build-thing declares no package cell '{}'", self.variant()))
    }

    /// Where this release's set lands on the host — the cell's `out_dir`.
    pub fn dist_dir(self, root: &Path) -> Result<PathBuf, String> {
        Ok(root.join(self.cell()?.out_dir))
    }

    /// Read a built wheel of THIS release back — see [`verify_wheel`] /
    /// [`verify_demo_wheel`].
    pub fn verify(self, wheel: &Path) -> Result<WheelReport, WheelFault> {
        match self {
            Release::Components => verify_wheel(wheel),
            Release::Demo => verify_demo_wheel(wheel),
        }
    }

    /// Which release a wheel FILE belongs to, by its distribution field.
    #[must_use]
    pub fn of_wheel(file_name: &str) -> Option<Release> {
        let (dist, _) = parse_wheel_name(file_name).ok()?;
        Release::ALL.into_iter().find(|r| r.import_package() == dist)
    }
}

/// The modules the components wheel must carry besides its assets: the package, the
/// Streamlit glue (the `[streamlit]` extra's code — the extra only adds the
/// DEPENDENCY, the module ships in every wheel and guards its own import), and the
/// pre-0.2.0 import name's alias.
#[must_use]
pub fn required_component_modules() -> Vec<String> {
    vec![
        format!("{IMPORT_PACKAGE}/__init__.py"),
        format!("{IMPORT_PACKAGE}/streamlit.py"),
        format!("{}/__init__.py", facett_build_thing::COMPAT_IMPORT),
    ]
}

/// What the demo wheel must carry: the app Streamlit runs, the console script's
/// module, the two data files it READS at run time (package data that setuptools
/// silently leaves out unless declared — the reason this list exists), the saga wire
/// subset, and the baked build stamp `publish-pip` writes into the staged package.
#[must_use]
pub fn required_demo_files() -> Vec<String> {
    let p = facett_build_thing::DEMO_DISTRIBUTION;
    ["__init__.py", "app.py", "cli.py", "client.py", "facet_host.py", "_buildinfo.py",
     "demo_payloads.json", "errcodes.json", "proto/saga.proto", "panes/gallery_pane.py",
     "panes/offline_pane.py"]
        .iter()
        .map(|f| format!("{p}/{f}"))
        .collect()
}

/// The files the wheel MUST carry under `facett/assets/`, and without
/// which it is a delivery vehicle delivering nothing — as paths under `assets/`.
///
/// Every BUNDLE GROUP's three build outputs (the wasm module, its wasm-bindgen JS
/// glue, the component manifest — core at `assets/`, every other group under
/// `assets/<group>/`), read off the recipe's package cell `inputs`, which the recipe
/// derives from `BUNDLE_GROUPS`; then the two package-SOURCE files: `index.html`, the
/// loader page, and the routing table it reads (emitted from the same recipe). The
/// loader draws NOTHING — it is a `<canvas>`, the streamlit handshake and the
/// `facet:*` message protocol. Every pixel comes from the Rust modules beside it.
#[must_use]
pub fn required_assets() -> Vec<String> {
    let mut v: Vec<String> = staged_bundle_files().into_iter().map(|(_, asset)| asset).collect();
    v.push("index.html".to_string());
    v.push(facett_build_thing::ROUTING_FILE.to_string());
    v
}

/// `(repo-relative source, path under assets/)` for every bundle file the wheel
/// stages — the recipe's package cell `inputs`, nothing re-spelled.
#[must_use]
pub fn staged_bundle_files() -> Vec<(String, String)> {
    let set = facett_build_thing::declared();
    let pkg = set
        .package(facett_build_thing::VARIANT)
        .expect("the recipe declares its package cell");
    let prefix = format!("src/{IMPORT_PACKAGE}/assets/");
    pkg.inputs
        .iter()
        .map(|row| {
            let (from, to) = pkg.input_pair(row).expect("the recipe's input rows are well formed");
            let asset = to.strip_prefix(&prefix).unwrap_or(&to).to_string();
            (from, asset)
        })
        .collect()
}

/// A wasm module among them — each one's `\0asm` magic is checked.
fn is_wasm_module(asset: &str) -> bool {
    asset.ends_with(".wasm")
}

/// What a wheel can be wrong in. Each variant is produced by a dedicated red test.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WheelFault {
    /// The file does not exist, or is not readable.
    Unreadable(String),
    /// The bytes are not a zip archive at all.
    NotAZip(String),
    /// No `*.dist-info/RECORD` member — the wheel has no manifest of itself.
    NoRecord,
    /// Two or more `*.dist-info/RECORD` members: which one is the truth is undefined.
    AmbiguousRecord(Vec<String>),
    /// A `RECORD` line names a file the archive does not contain.
    MissingFromArchive(String),
    /// A file's sha256 differs from the one `RECORD` claims — the bytes changed
    /// after the record was written.
    RecordHashMismatch { path: String, recorded: String, actual: String },
    /// A file's length differs from the one `RECORD` claims.
    RecordSizeMismatch { path: String, recorded: u64, actual: u64 },
    /// The archive carries a file that `RECORD` does not list. `pip` installs it and
    /// never uninstalls it, so an unrecorded file is a permanent tenant.
    UnrecordedFile(String),
    /// A required shipped asset is absent from the wheel.
    MissingAsset(String),
    /// A required asset is present and EMPTY. The whole reason this check is not
    /// "the path exists": a zero-byte member records and hashes perfectly.
    EmptyAsset(String),
    /// `facet_wrapped_bg.wasm` does not begin with the `\0asm` magic — it is some
    /// other file wearing the name.
    NotWasm { first_bytes: Vec<u8> },
    /// The filename is not a PEP 427 wheel name.
    BadWheelName(String),
    /// A required MODULE (or demo file) is absent — the wheel installs and the import,
    /// the console script or a run-time data read fails on the user's machine.
    MissingModule(String),
    /// The wheel's version is not the one the recipe releases ([`facett_build_thing::PYPI_VERSION`]).
    VersionMismatch { wheel: String, declared: String },
    /// The demo wheel installs no `facett-demo` console script (or a different target).
    MissingEntryPoint { want: String, found: String },
    /// METADATA lacks a requirement or extra the release promises.
    MissingRequirement(String),
}

impl std::fmt::Display for WheelFault {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Unreadable(p) => write!(f, "wheel unreadable: {p}"),
            Self::NotAZip(p) => write!(f, "not a zip archive: {p}"),
            Self::NoRecord => write!(f, "no *.dist-info/RECORD member — the wheel does not describe itself"),
            Self::AmbiguousRecord(v) => write!(f, "{} RECORD members, expected exactly 1: {v:?}", v.len()),
            Self::MissingFromArchive(p) => write!(f, "RECORD names `{p}`, which is not in the archive"),
            Self::RecordHashMismatch { path, recorded, actual } => write!(
                f,
                "`{path}` does not match RECORD: recorded sha256={recorded}, actual sha256={actual}"
            ),
            Self::RecordSizeMismatch { path, recorded, actual } => {
                write!(f, "`{path}` is {actual} bytes, RECORD says {recorded}")
            }
            Self::UnrecordedFile(p) => write!(f, "`{p}` is in the archive but not in RECORD"),
            Self::MissingAsset(p) => write!(f, "required asset missing from the wheel: {p}"),
            Self::EmptyAsset(p) => write!(f, "required asset is EMPTY (0 bytes): {p}"),
            Self::NotWasm { first_bytes } => write!(
                f,
                "facet_wrapped_bg.wasm does not start with the \\0asm magic (got {first_bytes:02x?})"
            ),
            Self::BadWheelName(n) => write!(f, "not a PEP 427 wheel filename: {n}"),
            Self::MissingModule(p) => write!(f, "required module/file missing from the wheel: {p}"),
            Self::VersionMismatch { wheel, declared } => write!(
                f,
                "the wheel is version {wheel}, the recipe releases {declared} (facett-build-thing PYPI_VERSION)"
            ),
            Self::MissingEntryPoint { want, found } => write!(
                f,
                "entry_points.txt does not install `{want}` (found: {found})"
            ),
            Self::MissingRequirement(r) => write!(f, "METADATA does not declare {r}"),
        }
    }
}

impl std::error::Error for WheelFault {}

/// What a wheel turned out to hold. Returned only when every check above passed.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WheelReport {
    /// The distribution name read from the FILENAME (PEP 427 field 1).
    pub distribution: String,
    /// The version read from the filename (PEP 427 field 2).
    pub version: String,
    /// Every member of the archive, sorted.
    pub files: Vec<String>,
    /// How many files `RECORD` accounted for with a hash (its own line carries none).
    pub recorded: usize,
    /// `(asset name, byte length)` for each of [`required_assets`], in that order.
    /// The SIZES are returned, not a bool: a caller that prints them can see a wasm
    /// module shrink from megabytes to kilobytes, which a pass/fail cannot.
    pub assets: Vec<(String, u64)>,
}

impl WheelReport {
    /// The byte length of a required asset, by name.
    pub fn asset_len(&self, name: &str) -> Option<u64> {
        self.assets.iter().find(|(n, _)| n == name).map(|(_, l)| *l)
    }
}

/// The `RECORD` hash encoding: PEP 376 spells it `sha256=<urlsafe base64, no padding>`.
fn record_hash(bytes: &[u8]) -> String {
    let digest = Sha256::digest(bytes);
    format!("sha256={}", base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest))
}

/// Split a PEP 427 wheel filename into `(distribution, version)`.
///
/// `{distribution}-{version}(-{build})?-{python}-{abi}-{platform}.whl` — so the
/// first two `-`-separated fields, with at least five fields overall.
pub fn parse_wheel_name(file_name: &str) -> Result<(String, String), WheelFault> {
    let stem = file_name
        .strip_suffix(".whl")
        .ok_or_else(|| WheelFault::BadWheelName(file_name.to_string()))?;
    let parts: Vec<&str> = stem.split('-').collect();
    if parts.len() < 5 || parts[0].is_empty() || parts[1].is_empty() {
        return Err(WheelFault::BadWheelName(file_name.to_string()));
    }
    Ok((parts[0].to_string(), parts[1].to_string()))
}

/// A wheel whose RECORD has been checked against its bytes: the filename's
/// `(distribution, version)`, every member, and how many RECORD lines carried a hash.
struct RecordedWheel {
    distribution: String,
    version: String,
    members: BTreeMap<String, Vec<u8>>,
    recorded: usize,
}

/// Read a built wheel back and prove it is what it claims to be — the COMPONENTS
/// wheel (`facett`): its RECORD (see [`read_recorded`]), every bundle group's
/// assets (non-empty, wasm magic), the modules ([`required_component_modules`]),
/// and the version the recipe releases.
///
/// This is deliberately a PURE function over a file on disk: it needs no container,
/// no network and no python, so the red tests can hand it hand-built wheels and the
/// live verb can hand it the one podman just produced — one writer, two callers
/// (LAW 5).
pub fn verify_wheel(path: &Path) -> Result<WheelReport, WheelFault> {
    let RecordedWheel { distribution, version, members, recorded } = read_recorded(path)?;

    // ── the payload is actually in there ────────────────────────────────────
    let required = required_assets();
    let mut assets = Vec::with_capacity(required.len());
    for asset in &required {
        let path_in_wheel = format!("{IMPORT_PACKAGE}/assets/{asset}");
        let Some(bytes) = members.get(&path_in_wheel) else {
            return Err(WheelFault::MissingAsset(path_in_wheel));
        };
        if bytes.is_empty() {
            return Err(WheelFault::EmptyAsset(path_in_wheel));
        }
        if is_wasm_module(asset) && !bytes.starts_with(b"\0asm") {
            return Err(WheelFault::NotWasm {
                first_bytes: bytes.iter().take(4).copied().collect(),
            });
        }
        assets.push((asset.to_string(), bytes.len() as u64));
    }
    for m in required_component_modules() {
        if !members.contains_key(&m) {
            return Err(WheelFault::MissingModule(m));
        }
    }
    check_version(&version)?;

    Ok(WheelReport {
        distribution,
        version,
        files: members.keys().cloned().collect(),
        recorded,
        assets,
    })
}

/// The version gate both wheels pass: the recipe's [`facett_build_thing::PYPI_VERSION`].
fn check_version(version: &str) -> Result<(), WheelFault> {
    if version != facett_build_thing::PYPI_VERSION {
        return Err(WheelFault::VersionMismatch {
            wheel: version.to_string(),
            declared: facett_build_thing::PYPI_VERSION.to_string(),
        });
    }
    Ok(())
}

/// The one `*.dist-info/<name>` member of a wheel, as text.
fn dist_info_text(members: &BTreeMap<String, Vec<u8>>, name: &str) -> Option<String> {
    members
        .iter()
        .find(|(k, _)| k.ends_with(&format!(".dist-info/{name}")) && k.matches('/').count() == 1)
        .map(|(_, v)| String::from_utf8_lossy(v).into_owned())
}

/// Read the DEMO wheel (`facett-demo`) back: its RECORD, the files the app needs at
/// run time ([`required_demo_files`] — the two JSON tables are exactly the kind of
/// package data setuptools leaves out unless told), the `facett-demo` console
/// script, the `facett[streamlit]` requirement and the optional `korp` extra — the
/// three promises the package makes to `pip` — and the version.
///
/// `assets` in the report carries `(file, bytes)` for each required file.
pub fn verify_demo_wheel(path: &Path) -> Result<WheelReport, WheelFault> {
    let RecordedWheel { distribution, version, members, recorded } = read_recorded(path)?;

    let mut assets = Vec::new();
    for f in required_demo_files() {
        let Some(bytes) = members.get(&f) else {
            return Err(WheelFault::MissingModule(f));
        };
        if bytes.is_empty() && !f.ends_with("__init__.py") {
            return Err(WheelFault::EmptyAsset(f));
        }
        assets.push((f, bytes.len() as u64));
    }

    // The console script: `[console_scripts]` / `facett-demo = facett_demo.cli:main`.
    let (cmd, target) = facett_build_thing::DEMO_ENTRY_POINT;
    let eps = dist_info_text(&members, "entry_points.txt").unwrap_or_default();
    let mut in_console = false;
    let mut found = Vec::new();
    for line in eps.lines() {
        let t = line.trim();
        if t.starts_with('[') {
            in_console = t == "[console_scripts]";
            continue;
        }
        if in_console && let Some((k, v)) = t.split_once('=') {
            found.push(format!("{} = {}", k.trim(), v.trim()));
        }
    }
    let want = format!("{cmd} = {target}");
    if !found.contains(&want) {
        return Err(WheelFault::MissingEntryPoint {
            want,
            found: if found.is_empty() { "nothing".to_string() } else { found.join(", ") },
        });
    }

    // The requirements: facett[streamlit] unconditionally, and a `korp` extra.
    let md = dist_info_text(&members, "METADATA").unwrap_or_default();
    let header: Vec<&str> = md.split("\n\n").next().unwrap_or("").lines().collect();
    let needs_components = header.iter().any(|l| {
        l.strip_prefix("Requires-Dist:").is_some_and(|r| {
            let r = r.trim();
            r.starts_with(&format!("{}[streamlit]", facett_build_thing::PYPI_PROJECT)) && !r.contains("extra ==")
        })
    });
    if !needs_components {
        return Err(WheelFault::MissingRequirement(format!(
            "`Requires-Dist: {}[streamlit]` (unconditional)",
            facett_build_thing::PYPI_PROJECT
        )));
    }
    if !header.iter().any(|l| l.trim() == "Provides-Extra: korp") {
        return Err(WheelFault::MissingRequirement("`Provides-Extra: korp`".to_string()));
    }
    check_version(&version)?;

    Ok(WheelReport {
        distribution,
        version,
        files: members.keys().cloned().collect(),
        recorded,
        assets,
    })
}

/// Open a wheel and check it against its own `RECORD`: every recorded file is present
/// with the recorded sha256 and size, nothing rides along unrecorded, exactly one
/// RECORD exists. The release-specific checks run on what this returns.
fn read_recorded(path: &Path) -> Result<RecordedWheel, WheelFault> {
    let file_name = path
        .file_name()
        .and_then(|s| s.to_str())
        .ok_or_else(|| WheelFault::Unreadable(path.display().to_string()))?
        .to_string();
    let (distribution, version) = parse_wheel_name(&file_name)?;

    let f = std::fs::File::open(path)
        .map_err(|e| WheelFault::Unreadable(format!("{}: {e}", path.display())))?;
    let mut zip = zip::ZipArchive::new(f)
        .map_err(|e| WheelFault::NotAZip(format!("{}: {e}", path.display())))?;

    // Slurp the archive once: a wheel is small, and reading each member exactly once
    // keeps the hash and the length talking about the same bytes.
    let mut members: BTreeMap<String, Vec<u8>> = BTreeMap::new();
    for i in 0..zip.len() {
        let mut entry = zip
            .by_index(i)
            .map_err(|e| WheelFault::NotAZip(format!("member {i}: {e}")))?;
        if entry.is_dir() {
            continue;
        }
        let name = entry
            .name()
            .map_err(|e| WheelFault::NotAZip(format!("member {i}: {e}")))?
            .to_string();
        let mut buf = Vec::with_capacity(entry.size() as usize);
        entry
            .read_to_end(&mut buf)
            .map_err(|e| WheelFault::NotAZip(format!("member `{name}`: {e}")))?;
        members.insert(name, buf);
    }

    // ── the wheel's own manifest ────────────────────────────────────────────
    let record_names: Vec<String> = members
        .keys()
        .filter(|k| k.ends_with(".dist-info/RECORD"))
        .cloned()
        .collect();
    let record_name = match record_names.len() {
        0 => return Err(WheelFault::NoRecord),
        1 => record_names[0].clone(),
        _ => return Err(WheelFault::AmbiguousRecord(record_names)),
    };
    let record_text = String::from_utf8_lossy(&members[&record_name]).into_owned();

    let mut listed: BTreeMap<String, (String, String)> = BTreeMap::new();
    for line in record_text.lines().filter(|l| !l.trim().is_empty()) {
        // `path,sha256=…,size`. The path itself never contains a comma in a wheel
        // this build produces, and splitting from the RIGHT keeps that true even if
        // one ever did.
        let mut fields = line.rsplitn(3, ',');
        let size = fields.next().unwrap_or("").to_string();
        let hash = fields.next().unwrap_or("").to_string();
        let Some(p) = fields.next() else { continue };
        listed.insert(p.to_string(), (hash, size));
    }
    if listed.is_empty() {
        return Err(WheelFault::NoRecord);
    }

    // ── every recorded file is present and matches ──────────────────────────
    let mut recorded = 0usize;
    for (p, (hash, size)) in &listed {
        let Some(bytes) = members.get(p) else {
            return Err(WheelFault::MissingFromArchive(p.clone()));
        };
        // RECORD lists ITSELF with empty hash and size — it cannot contain its own
        // digest. That empty line is the spec, not a hole in this check.
        if hash.is_empty() && size.is_empty() {
            continue;
        }
        let actual = record_hash(bytes);
        if &actual != hash {
            return Err(WheelFault::RecordHashMismatch {
                path: p.clone(),
                recorded: hash.clone(),
                actual,
            });
        }
        let recorded_size: u64 = size.parse().unwrap_or(u64::MAX);
        if recorded_size != bytes.len() as u64 {
            return Err(WheelFault::RecordSizeMismatch {
                path: p.clone(),
                recorded: recorded_size,
                actual: bytes.len() as u64,
            });
        }
        recorded += 1;
    }

    // ── and nothing rides along unrecorded ──────────────────────────────────
    for name in members.keys() {
        if !listed.contains_key(name) {
            return Err(WheelFault::UnrecordedFile(name.clone()));
        }
    }

    Ok(RecordedWheel { distribution, version, members, recorded })
}

/// Where the COMPONENTS wheel lands on the host — the package cell's `out_dir`
/// (the demo's is [`Release::dist_dir`]).
pub fn dist_dir(root: &Path) -> PathBuf {
    root.join(facett_build_thing::OUT_DIR)
}

/// The wasm bundle `cargo xtask wrap-wasm` writes, and this verb consumes — the
/// `[[build.wasm]]` cell's `out_dir`.
pub fn wasm_dist_dir(root: &Path) -> PathBuf {
    root.join(facett_build_thing::WASM_DIST)
}

// ─────────────────────────────────────────────────────────────────────────────
// THE VERB — build the wheel in a container, copy it out, read it back.
// ─────────────────────────────────────────────────────────────────────────────

use std::time::Duration;

/// The instant the image and the wheel are both stamped with.
///
/// A FIXED constant, not `now()`: the image ID is the digest of the image CONFIG,
/// and the config carries a `created` timestamp no Dockerfile instruction can reach.
/// With `now()` the same recipe mints a different ID every run — measured on oden
/// 2026-08-30, six builds of one Containerfile into six fresh stores gave six IDs.
/// Pinned here, the ID is a function of the recipe, and `SOURCE_DATE_EPOCH` in the
/// build container makes the wheel's own zip entries agree with it.
///
/// 2026-09-05T00:00:00Z — the day this recipe was written. Bump it when the recipe
/// changes, never per-run.
pub const TOOLCHAIN_EPOCH: i64 = 1_788_566_400;

/// The three files `cargo xtask wrap-wasm` must have produced before this verb can
/// do anything — the recipe's list, which is also the package cell's `inputs`.
/// `index.html` is NOT here: it is package source, not build output.
pub use facett_build_thing::WASM_OUTPUTS as WASM_BUILD_OUTPUTS;

/// Ceiling on the in-container build. `python -m build --no-isolation` on this
/// package is seconds; ten minutes is the "something is wrong" line, not a budget.
const BUILD_TIMEOUT: Duration = Duration::from_secs(600);

/// What a completed `publish-pip` produced, so the caller reports NUMBERS rather
/// than "ok" (LAW 7).
#[derive(Debug, Clone)]
pub struct PipBuildOutcome {
    /// Which of the two releases this is.
    pub release: Release,
    /// The image reference the reproducible build wrote.
    pub image_ref: String,
    /// The image ID the daemon reported for that reference — the REAL pin, since an
    /// image id is the digest of the image CONFIG, and the `timestamp`/`outputformat`
    /// parameters make that config a function of the recipe rather than of the minute
    /// the build ran.
    ///
    /// Until 2026-09-05 this could not be filled in at all: draupnir's `inspect_image`
    /// flattened the daemon's whole reply to Present/Absent, and the reply is where the
    /// id lives. `b48d6b6` fixed it and this is the first caller.
    ///
    /// `None` means the build succeeded and the engine could not name the id. It is
    /// deliberately NOT filled in with the tag — a tag sitting in an id field is
    /// exactly what a later reader would mistake for evidence of reproducibility.
    pub image_id: Option<String>,
    /// The wheel on the host, and what reading it back proved.
    pub wheel: PathBuf,
    pub report: WheelReport,
    /// The sdist, when one was produced.
    pub sdist: Option<PathBuf>,
}

/// Copy a directory tree. Small, explicit, and refuses symlinks rather than
/// following them into somewhere outside the package.
fn copy_tree(from: &Path, to: &Path) -> Result<(), String> {
    std::fs::create_dir_all(to).map_err(|e| format!("create {}: {e}", to.display()))?;
    for entry in std::fs::read_dir(from).map_err(|e| format!("read {}: {e}", from.display()))? {
        let entry = entry.map_err(|e| format!("read entry in {}: {e}", from.display()))?;
        let meta = entry
            .file_type()
            .map_err(|e| format!("stat {}: {e}", entry.path().display()))?;
        let dst = to.join(entry.file_name());
        if meta.is_symlink() {
            return Err(format!(
                "{} is a symlink — the staged package must be plain bytes, not a link out of it",
                entry.path().display()
            ));
        } else if meta.is_dir() {
            copy_tree(&entry.path(), &dst)?;
        } else {
            std::fs::copy(entry.path(), &dst)
                .map_err(|e| format!("copy {} → {}: {e}", entry.path().display(), dst.display()))?;
        }
    }
    Ok(())
}

/// Prove the wasm bundle is really there BEFORE starting a container, and name the
/// verb that produces it if it is not.
///
/// The magic-number check is the same reasoning as [`verify_wheel`]'s: `wrap-wasm`
/// writing an empty or non-wasm `facet_wrapped_bg.wasm` and this verb happily
/// packaging it is precisely how a green build ships a blank canvas.
pub fn check_wasm_bundle(root: &Path) -> Result<Vec<(String, u64)>, String> {
    // EVERY bundle group the wheel stages (the recipe's package inputs), not only core:
    // a wheel missing a group is a component the loader routes to and cannot load.
    let mut sizes = Vec::new();
    for (from, asset) in staged_bundle_files() {
        let p = root.join(&from);
        let dist = p.parent().unwrap_or(root);
        if !dist.is_dir() {
            return Err(format!(
                "{} does not exist — run `cargo xtask wrap-wasm` first (it builds every \
                 bundle group to wasm32 and wasm-bindgens each into its dist directory)",
                dist.display()
            ));
        }
        let meta = std::fs::metadata(&p)
            .map_err(|_| format!("{} missing — re-run `cargo xtask wrap-wasm`", p.display()))?;
        if meta.len() == 0 {
            return Err(format!("{} is 0 bytes — the wasm build produced nothing", p.display()));
        }
        if is_wasm_module(&asset) {
            let head = std::fs::read(&p)
                .map(|b| b.into_iter().take(4).collect::<Vec<u8>>())
                .unwrap_or_default();
            if !head.starts_with(b"\0asm") {
                return Err(format!(
                    "{} does not begin with the \\0asm magic (got {head:02x?}) — that is not a \
                     wasm module, and packaging it would ship a wheel that loads nothing",
                    p.display()
                ));
            }
        }
        sizes.push((asset, meta.len()));
    }
    Ok(sizes)
}

/// Stage the python package + the wasm bundle into a scratch tree the container
/// mounts.
///
/// **Staged OUTSIDE the repo on purpose.** The script this replaces
/// (`make_wheel.py`) copied the built wasm INTO `pkg-facet-wrapped/src/…/assets/`,
/// i.e. it wrote multi-megabyte build output into a tracked source directory every
/// time it ran. Every lane on oden has now been bitten by a shared checkout left
/// dirty by someone else's build; this one will not add to it. The repo is read
/// FROM, never written to.
pub fn stage_package(root: &Path, stage: &Path) -> Result<(), String> {
    let pkg = root.join(facett_build_thing::SOURCE);
    if stage.exists() {
        std::fs::remove_dir_all(stage).map_err(|e| format!("clear {}: {e}", stage.display()))?;
    }
    std::fs::create_dir_all(stage).map_err(|e| format!("create {}: {e}", stage.display()))?;

    for f in ["pyproject.toml", "README.md"] {
        let src = pkg.join(f);
        std::fs::copy(&src, stage.join(f))
            .map_err(|e| format!("copy {}: {e}", src.display()))?;
    }
    stage_license_texts(root, stage)?;
    copy_tree(&pkg.join("src"), &stage.join("src"))?;

    // ── THE RULE, ENFORCED ON THE BYTES THAT SHIP ────────────────────────
    //
    // "it must NOT use javascript to do graphics" is checked HERE, on the staged
    // loader page, so a page that draws cannot become a wheel. Putting it in the
    // build path rather than only in a test is the by-construction half (LAW 5):
    // the test proves the scanner works, this line makes it unavoidable.
    let loader = stage.join("src").join(IMPORT_PACKAGE).join("assets").join("index.html");
    let html = std::fs::read_to_string(&loader)
        .map_err(|e| format!("read {}: {e}", loader.display()))?;
    crate::jsguard::check(&loader.display().to_string(), &html)?;

    // The build outputs go in beside the loader page that already lives in src/.
    let assets = stage.join("src").join(IMPORT_PACKAGE).join("assets");
    std::fs::create_dir_all(&assets).map_err(|e| format!("create {}: {e}", assets.display()))?;
    // Every bundle group: core at `assets/`, the others under `assets/<group>/`.
    for (from, asset) in staged_bundle_files() {
        let dst = assets.join(&asset);
        if let Some(dir) = dst.parent() {
            std::fs::create_dir_all(dir).map_err(|e| format!("create {}: {e}", dir.display()))?;
        }
        std::fs::copy(root.join(&from), &dst).map_err(|e| format!("stage {from} → {asset}: {e}"))?;
    }
    Ok(())
}

/// The license texts both packages ship (`license-files` in each pyproject): ONE copy
/// each at the repository root, staged beside the pyproject because PEP 639 paths
/// may not leave the project directory. The wheel carries them under
/// `<dist-info>/licenses/`, the sdist at its top level, and `publish-pypi` refuses a
/// file whose `License-File` names a text it does not contain.
pub const LICENSE_TEXTS: [&str; 2] = ["LICENSE", "NOTICE"];

fn stage_license_texts(root: &Path, stage: &Path) -> Result<(), String> {
    for f in LICENSE_TEXTS {
        let src = root.join(f);
        std::fs::copy(&src, stage.join(f)).map_err(|e| format!("stage the license text {}: {e}", src.display()))?;
    }
    Ok(())
}

/// Build (or rebuild) the pinned toolchain image and return `(tag, id)`.
///
/// The id is the real pin: an image id is the digest of the image CONFIG, so with
/// the `timestamp`/`outputformat` parameters set it is a function of the recipe
/// rather than of the minute the build ran. Until 2026-09-05 nothing in this
/// constellation could name it — `inspect_image` flattened the daemon's whole reply
/// to Present/Absent and the reply is where the id lives. draupnir `b48d6b6` fixed
/// that; this is the first caller.
///
/// `None` means the build succeeded and the engine cannot name the id. It is
/// carried as `None` rather than falling back to the tag, because a tag recorded
/// in an ID field is precisely the kind of thing a later reader would take as
/// evidence of reproducibility and act on.
pub fn build_toolchain_image(
    root: &Path,
    tag: &str,
) -> Result<(String, Option<String>), String> {
    let ctx = root.join("target").join("pip-build").join("context");
    std::fs::create_dir_all(&ctx).map_err(|e| format!("create {}: {e}", ctx.display()))?;
    let cf = "pip-toolchain.Containerfile";
    std::fs::write(ctx.join(cf), PIP_TOOLCHAIN_CONTAINERFILE)
        .map_err(|e| format!("write {cf}: {e}"))?;

    println!("ops: building the pip toolchain image `{tag}` (reproducible, epoch {TOOLCHAIN_EPOCH})…");
    draupnir::container::ContainerBoot::new()
        .build_image_reproducible_pinned(&ctx, cf, tag, TOOLCHAIN_EPOCH)
        .map_err(|e| format!("build image `{tag}`: {e}"))
}

// ─────────────────────────────────────────────────────────────────────────────
// THE DEMO PACKAGE — pure Python, staged from `py/`, stamped from Rust.
// ─────────────────────────────────────────────────────────────────────────────

/// What a staged demo tree must never carry: interpreter caches, a stale baked stamp
/// from someone's checkout, the runtime scratch the app writes. Each would end up in
/// the wheel (setuptools packages every `.py` it finds) or shadow the stamp below.
fn demo_skip(name: &str) -> bool {
    name == "__pycache__" || name == "_buildinfo.py" || name == "_facet_host" || name.ends_with(".pyc")
}

/// Copy a tree like [`copy_tree`], skipping what [`demo_skip`] names.
fn copy_tree_filtered(from: &Path, to: &Path, skip: fn(&str) -> bool) -> Result<(), String> {
    std::fs::create_dir_all(to).map_err(|e| format!("create {}: {e}", to.display()))?;
    for entry in std::fs::read_dir(from).map_err(|e| format!("read {}: {e}", from.display()))? {
        let entry = entry.map_err(|e| format!("read entry in {}: {e}", from.display()))?;
        let name = entry.file_name().to_string_lossy().into_owned();
        if skip(&name) {
            continue;
        }
        let meta = entry.file_type().map_err(|e| format!("stat {}: {e}", entry.path().display()))?;
        let dst = to.join(&name);
        if meta.is_symlink() {
            return Err(format!("{} is a symlink — the staged package must be plain bytes", entry.path().display()));
        } else if meta.is_dir() {
            copy_tree_filtered(&entry.path(), &dst, skip)?;
        } else {
            std::fs::copy(entry.path(), &dst)
                .map_err(|e| format!("copy {} → {}: {e}", entry.path().display(), dst.display()))?;
        }
    }
    Ok(())
}

/// The git revision the release is cut from, read by edda's ONE git reader (the
/// `nornir-git` leaf, pure gix — no `git` CLI) through the provenance capture every
/// release record already makes: `(short sha, dirty)`.
fn git_revision(root: &Path) -> (String, bool) {
    use nornir_build_thing::provenance::{BuildProvenance, HostInfo, Outcome, Toolchain};
    let probe = BuildProvenance::capture(
        &nornir_build_thing::variant::BuildVariant::default(),
        "facett",
        root,
        Toolchain { rustc: String::new(), host: String::new(), cargo: String::new(), image: None, image_sha256: None },
        HostInfo { cores: 0, memory_bytes: 0 },
        Outcome::Success,
        &[],
    );
    match probe {
        Ok(p) if p.inputs.vcs_rev.len() >= 7 && p.inputs.vcs_rev != "unknown" => {
            (p.inputs.vcs_rev[..7].to_string(), p.inputs.vcs_dirty)
        }
        _ => ("unknown".to_string(), false),
    }
}

/// **The demo's baked build stamp** (`facett_demo/_buildinfo.py`) — the
/// build-version-githash LAW: every app shows WHICH build it is. It used to be written
/// by `py/scripts/stamp.py` on the host (python on the host; deleted). Now Rust writes
/// it into the STAGED package, never into the source tree.
///
/// Reproducible by construction: the hash is the commit's (with `+dirty` when the
/// tree was), and the "build time" is the recipe's `SOURCE_DATE_EPOCH`, said as one —
/// a wall-clock time here would make two builds of one commit differ in one file.
#[must_use]
pub fn buildinfo_module(git_hash: &str, epoch: i64) -> String {
    format!(
        "\"\"\"Baked build stamp — written by `cargo ops publish-pip` into the staged package. Do not edit.\"\"\"\n\
         GIT_HASH = \"{git_hash}\"\n\
         BUILD_TIME = \"SOURCE_DATE_EPOCH={epoch}\"\n"
    )
}

/// Stage the DEMO package (`py/`): `pyproject.toml`, `README.md` and the import
/// package, minus caches and any checkout-local stamp, plus the baked stamp. Tests,
/// scripts and requirements stay out — they are the repo's, not the release's.
pub fn stage_demo_package(root: &Path, stage: &Path) -> Result<(), String> {
    let cell = Release::Demo.cell()?;
    let src = root.join(cell.source.as_deref().unwrap_or(facett_build_thing::DEMO_SOURCE));
    if stage.exists() {
        std::fs::remove_dir_all(stage).map_err(|e| format!("clear {}: {e}", stage.display()))?;
    }
    std::fs::create_dir_all(stage).map_err(|e| format!("create {}: {e}", stage.display()))?;
    for f in ["pyproject.toml", "README.md"] {
        std::fs::copy(src.join(f), stage.join(f)).map_err(|e| format!("copy {}: {e}", src.join(f).display()))?;
    }
    stage_license_texts(root, stage)?;
    let pkg = Release::Demo.import_package();
    copy_tree_filtered(&src.join(pkg), &stage.join(pkg), demo_skip)?;

    let (hash, dirty) = git_revision(root);
    let stamp = if dirty { format!("{hash}+dirty") } else { hash };
    std::fs::write(stage.join(pkg).join("_buildinfo.py"), buildinfo_module(&stamp, TOOLCHAIN_EPOCH))
        .map_err(|e| format!("write the build stamp: {e}"))?;
    Ok(())
}

// ─────────────────────────────────────────────────────────────────────────────
// THE VERB — build both wheels in a container, copy them out, read them back.
// ─────────────────────────────────────────────────────────────────────────────

/// `cargo ops publish-pip` — the whole verb: BOTH releases, `facett` then
/// `facett-demo`, in one pinned image.
///
/// No python touches the host. The only host-side work is copying bytes and reading
/// a zip; `python -m build` runs in `nordisk-pip-toolchain` with the network OFF.
pub fn publish_pip(root: &Path) -> Result<Vec<PipBuildOutcome>, String> {
    publish_releases(root, &Release::ALL)
}

/// Build the named releases (see [`publish_pip`]).
pub fn publish_releases(root: &Path, releases: &[Release]) -> Result<Vec<PipBuildOutcome>, String> {
    // 1. The components' input must exist and be real wasm, said before a container
    //    starts. (The demo has no build inputs.)
    if releases.contains(&Release::Components) {
        for (f, n) in check_wasm_bundle(root)? {
            println!("ops: wrap-wasm output {f}: {n} bytes");
        }
    }

    // 2. The image — once, for both.
    let (image, image_id) = build_toolchain_image(root, PIP_TOOLCHAIN_IMAGE)?;
    match &image_id {
        Some(id) => println!("ops: image {image} = {id}"),
        None => println!(
            "ops: image {image} built, but the engine could not name its id — this build \
             is NOT pinned"
        ),
    }

    releases.iter().map(|r| publish_release(root, *r, &image, image_id.clone())).collect()
}

/// One release: stage, build in the container (network off), copy out, read back,
/// write the release set (`SHA256SUMS` + provenance), check the set is the declared one.
fn publish_release(
    root: &Path,
    release: Release,
    image: &str,
    image_id: Option<String>,
) -> Result<PipBuildOutcome, String> {
    let cell = release.cell()?;

    // 3. The staged package, outside the repo.
    let stage = root.join("target").join("pip-build").join(release.variant());
    match release {
        Release::Components => stage_package(root, &stage)?,
        Release::Demo => stage_demo_package(root, &stage)?,
    }
    println!("ops: [{}] staged package at {}", release.project(), stage.display());

    // 4. The build, in the container, with the network off.
    //
    // The script is edda's (`package::build_script`), the SAME writer the forge's
    // package builder runs — so `--sdist` follows the cell's declaration (`facett`:
    // wheel only; `facett-demo`: wheel + sdist) rather than a literal here.
    // `--no-isolation` is not a shortcut: with isolation `python -m build` creates a
    // venv and DOWNLOADS setuptools from PyPI, which cannot happen on `--network
    // none`. `SOURCE_DATE_EPOCH` makes the zip entry timestamps a function of the
    // recipe rather than of the minute.
    let script = nornir_build_thing::package::build_script(&cell, TOOLCHAIN_EPOCH);
    let spec = draupnir::BootSpec::container("facett-pip-build", image)
        .with_cmd(["/bin/sh", "-eu", "-c", script.as_str()])
        .with_net(draupnir::NetMode::None)
        .with_mem_limit_mb(2048);
    spec.validate().map_err(|e| format!("pip build spec: {e}"))?;
    let opts = draupnir::container::RunOptions::bounded(BUILD_TIMEOUT, Duration::from_secs(1));
    let binds = vec![format!("{}:/pkg", stage.display())];

    println!("ops: [{}] python -m build, inside {PIP_TOOLCHAIN_IMAGE}, network none…", release.project());
    let out = draupnir::container::ContainerBoot::new()
        .run_to_completion_with_binds(&spec, &opts, &binds)
        .map_err(|e| format!("pip build container: {e}"))?;

    if out.exit_code != Some(0) {
        // Say what the build said. A red with no reason is what makes people re-run
        // a build to find out why it broke.
        for l in out.stdout.iter().rev().take(20).collect::<Vec<_>>().into_iter().rev() {
            eprintln!("[pip-build] {}", l.trim_end());
        }
        for l in out.stderr.iter().rev().take(30).collect::<Vec<_>>().into_iter().rev() {
            eprintln!("[pip-build] err: {}", l.trim_end());
        }
        return Err(format!("python -m build exited {:?} inside {image} ({})", out.exit_code, release.project()));
    }

    // 5. Copy the artifacts out to where a release reads them.
    let dist = release.dist_dir(root)?;
    if dist.exists() {
        std::fs::remove_dir_all(&dist).map_err(|e| format!("clear {}: {e}", dist.display()))?;
    }
    std::fs::create_dir_all(&dist).map_err(|e| format!("create {}: {e}", dist.display()))?;
    let mut wheel: Option<PathBuf> = None;
    let mut sdist: Option<PathBuf> = None;
    for entry in std::fs::read_dir(stage.join("dist"))
        .map_err(|e| format!("the container produced no dist/: {e}"))?
    {
        let entry = entry.map_err(|e| format!("read dist entry: {e}"))?;
        let dst = dist.join(entry.file_name());
        std::fs::copy(entry.path(), &dst)
            .map_err(|e| format!("copy out {}: {e}", entry.path().display()))?;
        let name = entry.file_name().to_string_lossy().into_owned();
        if name.ends_with(".whl") {
            wheel = Some(dst);
        } else if name.ends_with(".tar.gz") {
            sdist = Some(dst);
        }
    }
    let wheel = wheel.ok_or_else(|| {
        format!("the build exited 0 but produced no .whl in {}", stage.join("dist").display())
    })?;
    // An sdist the declaration does not ask for is a member nobody accounted for.
    if sdist.is_some() && !cell.sdist {
        return Err(format!("{} produced an sdist its cell declares it does not ship", release.project()));
    }

    // 6. Read the produced bytes BACK. This is the step that makes the verb worth
    //    anything: everything above is "the tool exited 0". Then the metadata PyPI
    //    will read — the uploader's own check, so a wheel PyPI would refuse fails HERE.
    let report = release
        .verify(&wheel)
        .map_err(|f| format!("the {} wheel we just built is bad: {f}", release.project()))?;
    crate::pypi::check_wheel_offline(&wheel)
        .map_err(|e| format!("the {} wheel would be refused by PyPI: {e}", release.project()))?;
    // The sdist passes the same name gate the wheel just did (inside check_wheel_offline).
    if let Some(s) = &sdist {
        crate::pypi::name_gate(&crate::pypi::read_dist(s)?)
            .map_err(|e| format!("the {} sdist must not be published: {e}", release.project()))?;
    }
    let outcome = PipBuildOutcome { release, image_ref: image.to_string(), image_id, wheel, report, sdist };

    // 7. The RELEASE SET. A verified wheel on disk is not a release: nothing beside
    //    it lets a consumer check the bytes away from this machine. `SHA256SUMS` is
    //    what `sha256sum -c` reads, and the provenance record content-hashes the
    //    PRODUCED bytes rather than the recipe that was meant to produce them.
    //
    //    Written HERE rather than in a separate verb so the set cannot be forgotten:
    //    every `publish-pip` leaves a complete, checkable release behind it.
    let sums = write_sha256sums(&dist)?;
    let prov = capture_provenance(root, &outcome)?;
    println!("ops: [{}] {} + {}", release.project(), sums.display(), prov.display());
    // The set the DECLARATION says must be here — read back off disk, not assumed
    // from the fact that the writers above returned Ok. The same check the forge
    // door makes, through the same function, so the two doors cannot disagree about
    // what a release is.
    nornir_build_thing::package::verify_produced(&cell, &dist)
        .map_err(|e| format!("the release set is incomplete: {e:#}"))?;

    Ok(outcome)
}

// ─────────────────────────────────────────────────────────────────────────────
// ROW 10 — the release SET: the artefacts, plus a statement of what they are.
// ─────────────────────────────────────────────────────────────────────────────

/// The checksum file a downloader runs `sha256sum -c` against. Re-exported from
/// edda rather than spelled again: the manifest's NAME is part of what makes the
/// two doors the same door.
pub use nornir_build_thing::package::SUMS_FILE;

/// Lowercase hex sha256 — edda's, the one spelling the manifest writer and every
/// test of it use, so a test cannot agree with a bug by hashing the same wrong way.
pub use nornir_build_thing::package::sha256_hex;

/// **Write `SHA256SUMS` beside the artefacts** — through edda's ONE writer
/// (`nornir_build_thing::package::write_sha256sums`), which the forge's package door
/// calls too, so both doors leave the same release set behind (LAW 5).
///
/// `sha256sum -c` format exactly, the name BARE so the file is checkable from the
/// directory it ships in, never listing ITSELF (a self-hash is unverifiable) and never
/// listing a stray file, idempotent under re-runs, and an EMPTY directory refused
/// rather than written as an empty manifest that passes `sha256sum -c` trivially.
pub fn write_sha256sums(dist: &Path) -> Result<PathBuf, String> {
    nornir_build_thing::package::write_sha256sums(
        nornir_build_thing::package::PackageFormat::Pip,
        dist,
    )
    .map_err(|e| format!("{e:#}"))
}

/// **Capture provenance for the PRODUCED BYTES** — through edda's ONE writer.
///
/// `BuildProvenance::capture` is handed the artefact PATHS and content-hashes what is
/// actually there — not the recipe that was supposed to produce it: two builds of one
/// declaration can differ (row 3 measured the wasm growing 9 920 451 → 11 604 884
/// bytes from a feature flip). The variant is the DECLARED row of this release, and
/// the toolchain is the CONTAINER's image id — `Toolchain::detect()` here would attest
/// to the host's rustc, which did not build the artefact being described.
pub fn capture_provenance(
    root: &Path,
    outcome: &PipBuildOutcome,
) -> Result<PathBuf, String> {
    use nornir_build_thing::provenance::Toolchain;

    let mut artifacts: Vec<PathBuf> = vec![outcome.wheel.clone()];
    if let Some(s) = &outcome.sdist {
        artifacts.push(s.clone());
    }

    let set = facett_build_thing::declared();
    let variant = set
        .get(outcome.release.variant())
        .ok_or_else(|| format!("facett-build-thing declares no variant '{}'", outcome.release.variant()))?;
    let cell = outcome.release.cell()?;

    nornir_build_thing::package::write_provenance(
        &cell,
        variant,
        "facett",
        root,
        Toolchain {
            rustc: outcome.image_id.clone().unwrap_or_else(|| outcome.image_ref.clone()),
            host: String::new(),
            cargo: String::new(),
            // The container the wheel was built in, by name. Its MANIFEST digest is not
            // known here (`image_id` is the config digest), so that stays an absence.
            image: Some(outcome.image_ref.clone()),
            image_sha256: None,
        },
        &artifacts,
    )
    .map_err(|e| format!("capture provenance: {e:#}"))
}