feat/release-gate-whole-stack #59

Merged
clawdie merged 3 commits from feat/release-gate-whole-stack into main 2026-06-15 17:10:46 +02:00
7 changed files with 133 additions and 30 deletions

View file

@ -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:

View file

@ -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`.

View file

@ -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.

View file

@ -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" <<EOF
@ -1104,7 +1154,7 @@ seed_live_ai_source_repo() {
"origin": "$(json_escape "${_repo_origin}")",
"branch": "$(json_escape "${_repo_branch}")",
"commit": "$(json_escape "${_repo_commit}")",
"dirty_at_build": ${_repo_dirty},
"modified_at_build": ${_repo_modified},
"iso_version": "$(json_escape "${ISO_VERSION}")",
"build_channel": "$(json_escape "${BUILD_CHANNEL}")",
"snapshot_kind": "shallow git checkout",
@ -1129,7 +1179,7 @@ available.
No API keys, .env files, SSH private keys, build caches, package caches, tmp/
directories, or uncommitted worktree changes are included. Each checkout keeps a
.git directory plus a .clawdie-source.json file recording the source remote,
branch, commit, dirty state, ISO version, and build channel at image build time.
branch, commit, modified state, ISO version, and build channel at image build time.
EOF
seed_live_ai_source_repo "${SCRIPT_DIR}" "clawdie-iso"
seed_live_ai_source_repo "${_resolved_clawdie_ai_repo}" "clawdie-ai"
@ -1858,6 +1908,9 @@ EOF
chroot "${MOUNT_POINT}" chown -h clawdie:clawdie /home/clawdie/.cache
}
if [ "${BUILD_CHANNEL}" = "release" ]; then
check_release_gate
fi
preflight_colibri_artifacts
preflight_zot_artifacts

View file

@ -52,7 +52,7 @@ Use for the build result, not for the embedded image manifest alone. Include:
- build command and flags
- FreeBSD version/architecture
- source branch/commit/dirty state
- source branch/commit/modified state
- Colibri staging mode and artifact directory when `FEATURE_COLIBRI=YES`
- output image path, compressed image path when present, checksum path when present
- static checks run before build (`sh -n`, markdown format, Colibri preflight)

50
scripts/test-release-gate.sh Executable file
View file

@ -0,0 +1,50 @@
#!/bin/sh
# Smoke test for the release-channel clean-repo gate in build.sh.
#
# Why this exists: the gate only runs on BUILD_CHANNEL=release, so `sh -n`,
# prettier, and dev builds never exercise it — a broken gate ships green. This
# test pins the two properties we depend on:
# 1. assert_clean_repo fails a tree with untracked OR modified files, and
# passes a clean tree (the porcelain check, not diff-only).
# 2. check_release_gate is invoked AFTER it is defined in build.sh (guards the
# call-before-definition regression that aborts release builds with exit 127).
#
# Run: sh scripts/test-release-gate.sh
set -eu
SCRIPT_DIR=$(cd "$(dirname "$0")/.." && pwd)
BUILD_SH="${SCRIPT_DIR}/build.sh"
TMP=$(mktemp -d "${TMPDIR:-/tmp}/release-gate-test.XXXXXX")
trap 'rm -rf "${TMP}"' EXIT
fail=0
check() { if [ "$1" = "$2" ]; then echo "ok - $3"; else echo "FAIL - $3 (want '$2', got '$1')"; fail=1; fi; }
# --- property 2: definition precedes invocation -------------------------------
_def=$(grep -n '^check_release_gate() {' "${BUILD_SH}" | head -1 | cut -d: -f1)
_call=$(grep -n '^[[:space:]]*check_release_gate$' "${BUILD_SH}" | head -1 | cut -d: -f1)
if [ -n "${_def}" ] && [ -n "${_call}" ] && [ "${_call}" -gt "${_def}" ]; then
echo "ok - check_release_gate invoked after its definition (def ${_def}, call ${_call})"
else
echo "FAIL - check_release_gate call/definition order (def '${_def}', call '${_call}')"; fail=1
fi
# --- property 1: assert_clean_repo semantics ----------------------------------
# Extract the helper from build.sh and exercise it against real temp git repos.
sed -n '/^assert_clean_repo() {/,/^}/p' "${BUILD_SH}" > "${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; }

View file

@ -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}" <<EOF
"host": "$(json_escape "${_host}")",
"branch": "$(json_escape "${_branch}")",
"commit": "$(json_escape "${_commit}")",
"repo_dirty": ${_repo_dirty},
"repo_modified": ${_repo_modified},
"started_at": null,
"completed_at": "$(json_escape "${_written_at}")",
"artifact_type": "operator-usb-image",