ci(ffi): wire integration tests + deterministic header drift path (todos 001, 006)

todo 001: add explicit `cargo test -p agent-desktop-ffi --tests` step so
the c_abi_harness and error_lifetime integration tests run on every PR.
`cargo test --lib --workspace` skipped them, which meant ABI regressions
could land without the harness catching them.

todo 006: replace `find target -path '*/agent_desktop.h' | head -1` with
a deterministic lookup. build.rs now stamps the absolute path of the
just-generated header at target/ffi-header-path.txt, and both CI and
scripts/update-ffi-header.sh read that file. Under a warm target/ cache
with multiple agent-desktop-ffi-<hash>/ dirs, `find | head -1` picked
arbitrarily and could compare a stale header — the stamp always points
to the header from the current build.
This commit is contained in:
Lahfir 2026-04-16 17:05:50 -07:00
parent 61172dc8d7
commit 981c108f34
3 changed files with 67 additions and 12 deletions

View file

@ -75,6 +75,13 @@ jobs:
- name: Unit tests
run: cargo test --lib --workspace
# The FFI crate ships integration harnesses under crates/ffi/tests/ that
# exercise the C-ABI from outside the crate (raw extern "C" decls, enum
# fuzzing, out-param zeroing, last-error lifetimes). The workspace --lib
# run skips them, so wire them in explicitly so regressions fail PR CI.
- name: FFI integration tests
run: cargo test -p agent-desktop-ffi --tests
# Use the ci profile (no LTO, opt-level 1) — fast compile, still checks the binary builds.
- name: Build binary (ci profile)
run: cargo build --profile ci
@ -95,9 +102,18 @@ jobs:
- name: FFI header drift check
run: |
GENERATED=$(find target -path '*/agent-desktop-ffi-*/out/agent_desktop.h' | head -1)
if [ -z "$GENERATED" ]; then
echo "FAIL: cbindgen did not produce a header in OUT_DIR"
# build.rs stamps target/ffi-header-path.txt with the absolute path
# to the header produced by the most recent build, so the drift
# check always compares against the just-generated artifact rather
# than an arbitrary match from `find | head -1` over a warm cache.
STAMP=target/ffi-header-path.txt
if [ ! -f "$STAMP" ]; then
echo "FAIL: $STAMP missing; build.rs did not stamp header path"
exit 1
fi
GENERATED=$(cat "$STAMP")
if [ ! -f "$GENERATED" ]; then
echo "FAIL: stamped header path does not exist: $GENERATED"
exit 1
fi
if ! diff -u crates/ffi/include/agent_desktop.h "$GENERATED"; then

View file

@ -1,5 +1,5 @@
use std::env;
use std::path::Path;
use std::path::{Path, PathBuf};
fn main() {
println!("cargo:rerun-if-changed=cbindgen.toml");
@ -51,4 +51,34 @@ fn main() {
// script auto-copied it, the drift check would self-heal instead of
// catching stale headers. Developers update the committed header by
// running `scripts/update-ffi-header.sh`.
// Stamp the absolute path to the just-generated header at a stable,
// deterministic location that CI and scripts can read without ever
// resorting to `find target | head -1` (which picks arbitrarily when
// multiple `agent-desktop-ffi-<hash>/` build dirs are cached). Walking
// 4 parents up from OUT_DIR yields the cargo target root:
// {target}/{profile}/build/{pkg-hash}/out → {target}
if let Some(target_root) = target_root_from_out_dir(Path::new(&out_dir)) {
let stamp = target_root.join("ffi-header-path.txt");
if let Err(err) = std::fs::write(&stamp, out_path.to_string_lossy().as_bytes()) {
println!(
"cargo:warning=failed to stamp FFI header path at {:?}: {}",
stamp, err
);
}
} else {
println!(
"cargo:warning=could not infer cargo target root from OUT_DIR={}; skipping ffi-header-path.txt",
out_dir
);
}
}
fn target_root_from_out_dir(out_dir: &Path) -> Option<PathBuf> {
// OUT_DIR = .../target/<profile>/build/<pkg-hash>/out
let mut current = out_dir;
for _ in 0..4 {
current = current.parent()?;
}
Some(current.to_path_buf())
}

View file

@ -2,18 +2,27 @@
set -euo pipefail
# Regenerate the committed FFI header from source.
# Run this after changing any `#[no_mangle] pub extern "C"` signatures
# in crates/ffi/src/ and commit the result.
#
# Run this after changing any `#[no_mangle] pub extern "C"` signatures in
# crates/ffi/src/ and commit the result. build.rs stamps the absolute path
# to the generated header at target/ffi-header-path.txt so we never have to
# guess which of several cached `agent-desktop-ffi-<hash>/` build dirs is
# current — `find target | head -1` would pick arbitrarily.
cargo build -p agent-desktop-ffi 2>/dev/null
ROOT=$(git rev-parse --show-toplevel)
cd "$ROOT"
GENERATED=$(find target -path '*/agent-desktop-ffi-*/out/agent_desktop.h' -newer crates/ffi/include/agent_desktop.h | head -1)
if [ -z "$GENERATED" ]; then
GENERATED=$(find target -path '*/agent-desktop-ffi-*/out/agent_desktop.h' | head -1)
cargo build -p agent-desktop-ffi >/dev/null 2>&1
STAMP=target/ffi-header-path.txt
if [ ! -f "$STAMP" ]; then
echo "ERROR: $STAMP was not produced by build.rs. Check the build output." >&2
exit 1
fi
if [ -z "$GENERATED" ]; then
echo "ERROR: cbindgen did not produce a header. Check build output." >&2
GENERATED=$(cat "$STAMP")
if [ ! -f "$GENERATED" ]; then
echo "ERROR: stamped header path does not exist: $GENERATED" >&2
exit 1
fi