diff --git a/BUILD.md b/BUILD.md index f3c4af6b..a7e63d4b 100644 --- a/BUILD.md +++ b/BUILD.md @@ -414,7 +414,7 @@ The installed system receives the same manifest. It records: - ISO version and build channel - FreeBSD version/arch - bundled Clawdie-AI ref and commit -- ISO repo commit and dirty state +- ISO repo commit and modified state - UTC build timestamp The final size output distinguishes: diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c973bb8..8a85f912 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,7 +21,7 @@ image is meant to work out of the box. Work continues from here toward `1.0.0`. ### Versioning - The ISO now carries its **own product version** and no longer borrows zot's number. `ISO_VERSION` is explicit (set in `build.cfg`); `auto`/zot-tracking is gone and a build with no version fails fast. Component versions (zot, colibri, clawdie-ai, clawdie-iso) are recorded as provenance in `build-manifest.json`. -- `build-manifest.json` now records `colibri_commit`/`colibri_dirty` — the image stages adjacent colibri binaries, so the commit that produced them is captured for reproducibility. +- `build-manifest.json` now records `colibri_commit`/`colibri_modified` — the image stages adjacent colibri binaries, so the commit that produced them is captured for reproducibility. ### Added - Live rebuild lane now covers the **whole agent stack**: `go` added to the live-operator package list and the `zot` source seeded at `/home/clawdie/ai/zot`, so a booted USB can rebuild zot (Go) as well as Colibri (Rust). See `docs/LIVE-COLIBRI-REBUILD.md`. diff --git a/PLAN-OPERATOR-USB-NEXT.md b/PLAN-OPERATOR-USB-NEXT.md index 00e6e4da..e53ef479 100644 --- a/PLAN-OPERATOR-USB-NEXT.md +++ b/PLAN-OPERATOR-USB-NEXT.md @@ -642,7 +642,7 @@ folder so `$HOME` stays uncluttered: - Keep provider authentication manual. The image may include code, but it must not bake provider credentials. - If full `.git` history is too large, use shallow clones or archive snapshots - plus a small manifest that records remote URL, branch, commit, and dirty + plus a small manifest that records remote URL, branch, commit, and modified state. - This is a live-debug aid, not a build-host replacement: Linux agents still do not build the ISO, and real hardware proof remains operator/Codex-owned. diff --git a/build.sh b/build.sh index 57b6d5b4..2ed51d2a 100755 --- a/build.sh +++ b/build.sh @@ -101,16 +101,8 @@ if [ -n "${SSH_PUBLIC_KEY:-}" ]; then fi fi -if [ "${BUILD_CHANNEL}" = "release" ]; then - case "${CLAWDIE_REF}" in - v[0-9]*.[0-9]*.[0-9]*) ;; - *) - echo "ERROR: release builds must pin a Clawdie-AI tag with --clawdie-version X.Y.Z" - echo " Current Clawdie ref: ${CLAWDIE_REF}" - exit 1 - ;; - esac -fi +# The release gate runs in the preflight section below — it depends on the +# resolve_* helpers, which are defined later in this script. # The ISO carries its own product version (ISO_VERSION in build.cfg). It does not # track any single component. Component versions are resolved below purely for @@ -467,18 +459,62 @@ is_pinned_clawdie_ref() { printf '%s' "$_ref" | grep -Eq '^[0-9a-fA-F]{40}$|^v[0-9]+\.[0-9]+\.[0-9]+$' } +# Assert a repo has no uncommitted *or untracked* changes. `git status --porcelain` +# is used (not `git diff`) so stray untracked files — which would change the build +# yet pass a diff-only check — also fail the gate. Increments _release_errors on a +# modified/untracked tree; a non-git path is skipped (nothing to assert). +assert_clean_repo() { + _acr_name="$1" + _acr_path="$2" + command -v git >/dev/null 2>&1 || return 0 + git -C "${_acr_path}" rev-parse --git-dir >/dev/null 2>&1 || return 0 + if [ -n "$(git -C "${_acr_path}" status --porcelain 2>/dev/null)" ]; then + echo "ERROR: release builds require a clean ${_acr_name} repo (no uncommitted or untracked files)" + echo " Modified tree at: ${_acr_path}" + _release_errors=$((_release_errors + 1)) + fi +} + +# Release builds must be reproducible from the recorded provenance. We do not +# require components to be on a named tag — a recorded commit is just as pinned — +# but every staged source must be a clean, committed tree so the manifest's +# commits fully describe the artifact. (build-manifest.json records each commit.) +check_release_gate() { + _release_errors=0 + + assert_clean_repo "clawdie-iso" "${SCRIPT_DIR}" + + resolve_clawdie_ai_repo + assert_clean_repo "clawdie-ai" "${_resolved_clawdie_ai_repo}" + + if [ "${FEATURE_COLIBRI:-NO}" = "YES" ]; then + resolve_colibri_paths + assert_clean_repo "colibri" "${_resolved_colibri_repo}" + + if [ "${COLIBRI_STAGE_AGENT:-YES}" = "YES" ]; then + resolve_zot_paths + assert_clean_repo "zot" "${_resolved_zot_repo}" + fi + fi + + if [ "${_release_errors}" -gt 0 ]; then + echo "ERROR: release build aborted — ${_release_errors} modified repo(s). Use BUILD_CHANNEL=dev for iteration builds." + exit 1 + fi +} + write_build_manifest() { _manifest_path="$1" _iso_repo_commit="unknown" - _iso_repo_dirty="null" + _iso_repo_modified="null" _live_ssh_pubkey_fp_json="null" _tailscale_auth_key_baked="${TAILSCALE_AUTH_KEY_BAKED:-false}" if command -v git >/dev/null 2>&1 && git -C "$SCRIPT_DIR" rev-parse --git-dir >/dev/null 2>&1; then _iso_repo_commit=$(git -C "$SCRIPT_DIR" rev-parse HEAD 2>/dev/null || echo unknown) if git -C "$SCRIPT_DIR" diff --quiet 2>/dev/null && git -C "$SCRIPT_DIR" diff --cached --quiet 2>/dev/null; then - _iso_repo_dirty="false" + _iso_repo_modified="false" else - _iso_repo_dirty="true" + _iso_repo_modified="true" fi fi if [ -n "${LIVE_SSH_PUBKEY_FP:-}" ]; then @@ -487,16 +523,29 @@ write_build_manifest() { # Colibri provenance: the image stages adjacent colibri binaries, so record # which commit produced them (the biggest gap for a reproducible release). _colibri_commit="unknown" - _colibri_dirty="null" + _colibri_modified="null" if [ "${FEATURE_COLIBRI:-NO}" = "YES" ]; then resolve_colibri_paths if git -C "${_resolved_colibri_repo}" rev-parse --git-dir >/dev/null 2>&1; then _colibri_commit=$(git -C "${_resolved_colibri_repo}" rev-parse HEAD 2>/dev/null || echo unknown) if git -C "${_resolved_colibri_repo}" diff --quiet 2>/dev/null && \ git -C "${_resolved_colibri_repo}" diff --cached --quiet 2>/dev/null; then - _colibri_dirty="false" + _colibri_modified="false" else - _colibri_dirty="true" + _colibri_modified="true" + fi + fi + fi + # Zot provenance (agent binary built from source — record if repo is modified). + _zot_modified="null" + if [ "${FEATURE_COLIBRI:-NO}" = "YES" ] && [ "${COLIBRI_STAGE_AGENT:-YES}" = "YES" ]; then + resolve_zot_paths + if command -v git >/dev/null 2>&1 && git -C "${_resolved_zot_repo}" rev-parse --git-dir >/dev/null 2>&1; then + if git -C "${_resolved_zot_repo}" diff --quiet 2>/dev/null && \ + git -C "${_resolved_zot_repo}" diff --cached --quiet 2>/dev/null; then + _zot_modified="false" + else + _zot_modified="true" fi fi fi @@ -507,8 +556,9 @@ write_build_manifest() { "version_scheme": "product", "zot_version": "$(json_escape "${ZOT_RESOLVED_VERSION:-${ZOT_VERSION}}")", "zot_commit": "$(json_escape "${ZOT_RESOLVED_COMMIT:-unknown}")", + "zot_modified": ${_zot_modified:-null}, "colibri_commit": "$(json_escape "${_colibri_commit:-unknown}")", - "colibri_dirty": ${_colibri_dirty:-null}, + "colibri_modified": ${_colibri_modified:-null}, "build_channel": "$(json_escape "${BUILD_CHANNEL}")", "freebsd_version": "$(json_escape "${FREEBSD_VERSION}")", "freebsd_arch": "$(json_escape "${FREEBSD_ARCH}")", @@ -517,7 +567,7 @@ write_build_manifest() { "live_ssh_pubkey_fp": ${_live_ssh_pubkey_fp_json}, "tailscale_auth_key_baked": ${_tailscale_auth_key_baked}, "iso_repo_commit": "$(json_escape "${_iso_repo_commit}")", - "iso_repo_dirty": ${_iso_repo_dirty}, + "iso_repo_modified": ${_iso_repo_modified}, "built_at": "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" } EOF @@ -1092,9 +1142,9 @@ seed_live_ai_source_repo() { git -C "${_repo_dest}" remote set-url origin "${_repo_origin}" 2>/dev/null || true printf '%s\n' '.clawdie-source.json' >> "${_repo_dest}/.git/info/exclude" - _repo_dirty=false + _repo_modified=false if ! git -C "${_repo_src}" diff --quiet 2>/dev/null || ! git -C "${_repo_src}" diff --cached --quiet 2>/dev/null; then - _repo_dirty=true + _repo_modified=true fi cat > "${_repo_dest}/.clawdie-source.json" < "${TMP}/fn.sh" +. "${TMP}/fn.sh" + +mkrepo() { d="${TMP}/$1"; mkdir -p "$d"; git -C "$d" init -q; git -C "$d" config user.email t@t; git -C "$d" config user.name t; + echo base > "$d/file"; git -C "$d" add file; git -C "$d" commit -qm init; printf '%s' "$d"; } + +clean=$(mkrepo clean) +_release_errors=0; assert_clean_repo clean "${clean}"; check "${_release_errors}" 0 "clean repo passes" + +untracked=$(mkrepo untracked); : > "${untracked}/stray.txt" +_release_errors=0; assert_clean_repo untracked "${untracked}"; check "${_release_errors}" 1 "untracked file fails (porcelain catches it)" + +modified=$(mkrepo modified); echo changed > "${modified}/file" +_release_errors=0; assert_clean_repo modified "${modified}"; check "${_release_errors}" 1 "modified tracked file fails" + +_release_errors=0; assert_clean_repo nogit "${TMP}/does-not-exist"; check "${_release_errors}" 0 "non-git path is skipped" + +[ "${fail}" -eq 0 ] && echo "PASS: release gate smoke test" || { echo "FAILED"; exit 1; } diff --git a/scripts/write-artifact-manifest.sh b/scripts/write-artifact-manifest.sh index ca1bf5bb..8827e110 100755 --- a/scripts/write-artifact-manifest.sh +++ b/scripts/write-artifact-manifest.sh @@ -140,11 +140,11 @@ _xz_size="$(file_size "${_xz}")" _branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo unknown)" _commit="$(git rev-parse --short HEAD 2>/dev/null || echo unknown)" -_repo_dirty="null" +_repo_modified="null" if git diff --quiet 2>/dev/null && git diff --cached --quiet 2>/dev/null; then - _repo_dirty="false" + _repo_modified="false" elif git rev-parse --git-dir >/dev/null 2>&1; then - _repo_dirty="true" + _repo_modified="true" fi _host="$(hostname 2>/dev/null || echo unknown)" _written_at="$(date -u '+%Y-%m-%dT%H:%M:%SZ')" @@ -177,7 +177,7 @@ cat > "${_tmp}" <