cook: fail a build that stages no files

A recipe whose script exits 0 but installs nothing currently reports success,
and the damage lands far from the cause. qtspeech produced a 136-byte package
because upstream `return()`s with only a NOTICE when Qt6::Multimedia is absent;
that surfaced much later as an unresolved symbol in a consumer, not as a
qtspeech failure.

Zero regular files staged is unambiguous -- every real package installs at
least one file -- so this needs no per-recipe size threshold and cannot
false-positive on a small-but-valid package. The error names the likely cause
(a configure step soft-bailing on a missing optional dependency) rather than
just reporting emptiness. Metapackages that genuinely stage nothing opt out by
mentioning COOKBOOK_ALLOW_EMPTY_PACKAGE in their script.

This is the same failure shape as the libudev/libpciaccess API-surface risk: a
gap that reports success locally and only becomes visible as a link error in
something downstream.
This commit is contained in:
2026-08-04 20:00:25 +03:00
parent d7cfa84258
commit 5570ab1255
+43
View File
@@ -1179,6 +1179,49 @@ pub fn build(
globs.push((glob.compile_matcher(), stage_dir.clone()));
}
}
// EMPTY-PACKAGE GATE. A recipe whose script exits 0 but installs nothing
// is a build failure that currently reports success, and the damage
// lands far away: qtspeech produced a 136-byte package because upstream
// `return()`s with only a NOTICE when Qt6::Multimedia is missing, and
// that only surfaced much later as an unresolved symbol in a consumer.
//
// Zero regular files is unambiguous -- every real package installs at
// least one file -- so this needs no per-recipe threshold and cannot
// false-positive on a small-but-valid package. Recipes that legitimately
// stage nothing (metapackages) set COOKBOOK_ALLOW_EMPTY_PACKAGE=1.
{
fn count_files(dir: &Path) -> usize {
let mut n = 0;
if let Ok(entries) = fs::read_dir(dir) {
for e in entries.flatten() {
match e.file_type() {
Ok(t) if t.is_dir() => n += count_files(&e.path()),
Ok(_) => n += 1,
Err(_) => {}
}
}
}
n
}
let staged_files = count_files(&stage_dir_tmp);
let allow_empty = matches!(
&recipe.build.kind,
BuildKind::Custom { script } if script.contains("COOKBOOK_ALLOW_EMPTY_PACKAGE")
);
if staged_files == 0 && !allow_empty {
return Err(format!(
"{name}: build reported success but staged NO files.\n \
A package that installs nothing is a silent failure -- the error \
surfaces later as a missing header or unresolved symbol in a \
consumer, far from the cause.\n \
Check the recipe's configure step for a soft bail-out (a missing \
optional dependency that turns the build into a no-op).\n \
If this package genuinely stages nothing, mention \
COOKBOOK_ALLOW_EMPTY_PACKAGE in its script."
));
}
}
move_dir_all_fn(
&stage_dir_tmp,
&|path: PathBuf| {