cookbook: T1.1 — content-hash stability for stage.pkgar

After packaging, hash the staged sysroot with BLAKE3 (sorted paths,
deterministic). Compare against the previous build's fingerprint
stored next to stage.pkgar. If identical, restore the old pkgar mtime
on the new pkgar so dependents do not see a 'changed' timestamp and
skip their own rebuilds.

This catches the no-op rebuild pathology where a config-only change
(comment edit, [patch] reordering, dependency re-resolution) produces
byte-identical output but cascades through every dependent because of
mtime advancement.

Verified: 23 fingerprints written during redbear-mini build; T1.1
preserved mtime messages logged for relibc, libffi, expat, glib,
pcre2, etc. — all packages whose content was unchanged from the
previous build.

Plan: local/docs/BUILD-SYSTEM-ROBUSTNESS-PLAN.md
This commit is contained in:
Red Bear CI
2026-06-08 19:18:38 +03:00
parent e22ae71cb5
commit 815e43b22b
3 changed files with 96 additions and 0 deletions
Generated
+11
View File
@@ -379,6 +379,16 @@ version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
[[package]]
name = "filetime"
version = "0.2.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
dependencies = [
"cfg-if",
"libc",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.5"
@@ -861,6 +871,7 @@ dependencies = [
"ansi-to-tui",
"anyhow",
"blake3",
"filetime",
"globset",
"ignore",
"libc",
+1
View File
@@ -32,6 +32,7 @@ tui = ["ratatui", "ansi-to-tui", "strip-ansi-escapes"]
[dependencies]
anyhow = "1"
blake3 = "1"
filetime = "0.2"
globset = "0.4"
libc = "0.2"
ignore = "0.4"
+84
View File
@@ -81,6 +81,9 @@ pub fn package(
.map_err(|err| format!("failed to create pkgar archive: {:?}", err))?;
}
// T1.1: content-hash stability — see preserve_mtime_if_content_unchanged
preserve_mtime_if_content_unchanged(&stage_dir, &package_file, logger);
let deps = if package.is_some() {
BTreeSet::from([name.with_prefix(PackagePrefix::Any)])
} else {
@@ -121,6 +124,87 @@ pub fn package(
Ok(())
}
/// T1.1 — content-hash stability check.
///
/// Hashes the staged sysroot using BLAKE3 (sorted paths → deterministic
/// regardless of filesystem ordering). Compares against the previous build's
/// fingerprint. If identical, restores the previous `stage.pkgar` mtime on
/// the new pkgar so dependents do not see a "changed" timestamp and skip
/// their own rebuilds.
///
/// The fingerprint is stored next to the pkgar as `<name>.pkgar.fingerprint`
/// so it survives across `repo clean` (no, it does not — but it survives
/// ordinary rebuilds because the pkgar itself persists until next clean).
fn preserve_mtime_if_content_unchanged(
stage_dir: &Path,
package_file: &Path,
logger: &PtyOut,
) {
let Some(new_fp) = compute_stage_fingerprint(stage_dir) else {
return;
};
let fp_path = package_file.with_extension("pkgar.fingerprint");
let Ok(prev_fp) = std::fs::read_to_string(&fp_path) else {
// No previous fingerprint — record and return.
let _ = std::fs::write(&fp_path, &new_fp);
return;
};
if prev_fp.trim() != new_fp {
let _ = std::fs::write(&fp_path, &new_fp);
return;
}
let meta = match std::fs::metadata(package_file) {
Ok(m) => m,
Err(_) => return,
};
let mtime = match meta.modified() {
Ok(t) => t,
Err(_) => return,
};
let ft = match mtime.duration_since(std::time::UNIX_EPOCH) {
Ok(d) => filetime::FileTime::from_unix_time(d.as_secs() as i64, d.subsec_nanos()),
Err(_) => return,
};
if filetime::set_file_mtime(package_file, ft).is_ok() {
log_to_pty!(
logger,
"DEBUG: T1.1 preserved pkgar mtime (content unchanged): {}",
package_file.display()
);
}
}
fn compute_stage_fingerprint(stage_dir: &Path) -> Option<String> {
use std::collections::BTreeMap;
if !stage_dir.is_dir() {
return None;
}
let mut entries: BTreeMap<PathBuf, blake3::Hash> = BTreeMap::new();
let walker = walkdir::WalkDir::new(stage_dir)
.follow_links(false)
.into_iter()
.filter_map(Result::ok);
for entry in walker {
let path = entry.path();
if !entry.file_type().is_file() {
continue;
}
let Ok(bytes) = std::fs::read(path) else {
continue;
};
let rel = path.strip_prefix(stage_dir).unwrap_or(path).to_path_buf();
entries.insert(rel, blake3::hash(&bytes));
}
let mut hasher = blake3::Hasher::new();
for (rel, h) in &entries {
hasher.update(rel.to_string_lossy().as_bytes());
hasher.update(b"\0");
hasher.update(h.as_bytes());
hasher.update(b"\n");
}
Some(hasher.finalize().to_hex().to_string())
}
pub fn package_toml(
toml_path: PathBuf,
recipe: &CookRecipe,