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:#}"))
}