diff --git a/crates/ghostlight-dungeon/build.rs b/crates/ghostlight-dungeon/build.rs index d8427bd..9eaf054 100644 --- a/crates/ghostlight-dungeon/build.rs +++ b/crates/ghostlight-dungeon/build.rs @@ -1,30 +1,63 @@ -use std::process::Command; +use std::{env, path::Path, process::Command}; fn main() { println!("cargo:rerun-if-env-changed=GHOSTLIGHT_BUILD_COMMIT"); - println!("cargo:rerun-if-changed=../../.git/HEAD"); + let manifest_dir = + env::var("CARGO_MANIFEST_DIR").expect("Cargo must supply CARGO_MANIFEST_DIR"); + let repo_root = Path::new(&manifest_dir).join("../.."); + emit_git_rerun_paths(&repo_root); // Release tooling injects the clean-tree commit. Git discovery is only a - // convenience for local builds because Cargo cannot reliably observe a - // moving branch ref through `.git/HEAD` alone. - let commit = std::env::var("GHOSTLIGHT_BUILD_COMMIT") + // convenience for local builds. The exact ref and reflog dependencies above + // keep that convenience from silently publishing stale provenance. + let commit = env::var("GHOSTLIGHT_BUILD_COMMIT") .ok() .filter(|value| is_commit(value)) - .or_else(git_commit) + .or_else(|| git_commit(&repo_root)) .expect("GhostlightDungeon builds require an exact 40-hex source commit"); println!("cargo:rustc-env=GHOSTLIGHT_BUILD_COMMIT={commit}"); } -fn git_commit() -> Option { +fn emit_git_rerun_paths(repo_root: &Path) { + for git_path in ["HEAD", "logs/HEAD", "packed-refs"] { + if let Some(path) = git_output(repo_root, &["rev-parse", "--git-path", git_path]) { + emit_rerun_path(repo_root, &path); + } + } + + if let Some(reference) = git_output(repo_root, &["symbolic-ref", "-q", "HEAD"]) + && let Some(path) = git_output(repo_root, &["rev-parse", "--git-path", &reference]) + { + emit_rerun_path(repo_root, &path); + } +} + +fn emit_rerun_path(repo_root: &Path, git_path: &str) { + let path = Path::new(git_path); + let resolved = if path.is_absolute() { + path.to_path_buf() + } else { + repo_root.join(path) + }; + println!("cargo:rerun-if-changed={}", resolved.display()); +} + +fn git_commit(repo_root: &Path) -> Option { + git_output(repo_root, &["rev-parse", "HEAD"]).filter(|value| is_commit(value)) +} + +fn git_output(repo_root: &Path, args: &[&str]) -> Option { let output = Command::new("git") - .args(["rev-parse", "HEAD"]) - .current_dir("../..") + .args(args) + .current_dir(repo_root) .output() .ok()?; if !output.status.success() { return None; } - let value = String::from_utf8(output.stdout).ok()?.trim().to_owned(); - is_commit(&value).then_some(value) + String::from_utf8(output.stdout) + .ok() + .map(|value| value.trim().to_owned()) + .filter(|value| !value.is_empty()) } fn is_commit(value: &str) -> bool { diff --git a/crates/ghostlight-dungeon/tests/build_provenance.rs b/crates/ghostlight-dungeon/tests/build_provenance.rs new file mode 100644 index 0000000..b5ea6c5 --- /dev/null +++ b/crates/ghostlight-dungeon/tests/build_provenance.rs @@ -0,0 +1,22 @@ +use std::{path::Path, process::Command}; + +#[test] +fn embedded_commit_matches_checkout_head() { + let repo_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let output = Command::new("git") + .args(["rev-parse", "HEAD"]) + .current_dir(repo_root) + .output() + .expect("git must be available for the provenance regression test"); + assert!(output.status.success(), "git rev-parse HEAD failed"); + + let checkout_head = String::from_utf8(output.stdout) + .expect("git commit must be UTF-8") + .trim() + .to_owned(); + assert_eq!( + env!("GHOSTLIGHT_BUILD_COMMIT"), + checkout_head, + "the binary embedded stale Git provenance" + ); +} diff --git a/docs/architecture/ghostlight-dungeon-mvp.md b/docs/architecture/ghostlight-dungeon-mvp.md index 3be1fc5..fe2907c 100644 --- a/docs/architecture/ghostlight-dungeon-mvp.md +++ b/docs/architecture/ghostlight-dungeon-mvp.md @@ -758,6 +758,14 @@ atomic pointer switch, recorded in CultCache, and the rollback runbook lives in Task Scheduler may be added when tester availability makes that useful; native Windows service machinery is outside the MVP. +The binary owns its build provenance. Release tooling injects the clean-tree +commit through `GHOSTLIGHT_BUILD_COMMIT`; local builds derive it from Git while +the build script watches `HEAD`, its symbolic branch ref, the HEAD reflog, and +packed refs. The health projection only publishes that compile-time value. +Launchers and manifests may verify it but may not rewrite it. Activation is +rejected unless checkout HEAD, embedded commit, immutable manifest, binary +hash, and the running health projection agree. + Live VoidBot integration uses the independently verified Yggdrasil host and the restored Starfire loopback crossing at `127.0.0.1:17875/mcp`. The repaired tunnel task uses the live `F:\Projects` body. Retired local VoidBot/Qdrant diff --git a/notes/ghostlight-current-system-map.md b/notes/ghostlight-current-system-map.md index 7ce5ef5..528e8d3 100644 --- a/notes/ghostlight-current-system-map.md +++ b/notes/ghostlight-current-system-map.md @@ -23,6 +23,16 @@ Vault evidence receipts + typed campaign snapshot Ghostlight owns the generalized projection organ. Epiphany and other consumers own their canonical Persona state and consequence commits. +Build provenance has one owner: `crates/ghostlight-dungeon/build.rs` binds the +exact source commit into the binary. Its inputs are either the release tool's +explicit clean-tree commit or Git HEAD; it watches the actual symbolic ref, +HEAD reflog, and packed refs so an ordinary commit invalidates Cargo's cached +value. Health derives its commit from that embedded value. Release manifests, +launch scripts, and CultCache deployment receipts are verifiers and records, +not alternate writers. Local runs and immutable releases use this same binding, +and the regression test compares it to the checkout's live `git rev-parse +HEAD`. A mismatch blocks activation rather than being repaired after launch. + Away-time agency follows a separate proposal/commit seam: ```text