feat/release-gate-whole-stack #59
7 changed files with 133 additions and 30 deletions
2
BUILD.md
2
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:
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
97
build.sh
97
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" <<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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
50
scripts/test-release-gate.sh
Executable 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; }
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue