aboutsummaryrefslogtreecommitdiffstats
path: root/docs/plans/2026-07-13-image-builder.md
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-07-14 16:59:44 +0200
committerDanilo M. <danix@danix.xyz>2026-07-14 16:59:44 +0200
commit3cd610c212076d438f2d1103e0fc584a310011d5 (patch)
treeb82bd71ff139408dc84f685345862f944653966b /docs/plans/2026-07-13-image-builder.md
downloadsbo-dockerbuild-3cd610c212076d438f2d1103e0fc584a310011d5.tar.gz
sbo-dockerbuild-3cd610c212076d438f2d1103e0fc584a310011d5.zip
initial commit: docker test-build toolchain
Extracted from the .extras/ dir of the sbo-slackbuilds package repo, where it grew from a throwaway helper into a standalone tool. Git history starts fresh; the original 41-commit development log is preserved in HISTORY.md. Contents: the test-build CLI (resolves a local SlackBuild's deps from a configured SBo tree and builds it in a throwaway container, lints, caches deps), the image-builder chain that produces the images it consumes, their pure-logic self-checks, design specs/plans, an install.sh for ~/bin, and docs. Licensed GPLv2-only. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Diffstat (limited to 'docs/plans/2026-07-13-image-builder.md')
-rw-r--r--docs/plans/2026-07-13-image-builder.md790
1 files changed, 790 insertions, 0 deletions
diff --git a/docs/plans/2026-07-13-image-builder.md b/docs/plans/2026-07-13-image-builder.md
new file mode 100644
index 0000000..0c61051
--- /dev/null
+++ b/docs/plans/2026-07-13-image-builder.md
@@ -0,0 +1,790 @@
+# sbo-testbuild image builder — Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Build the three chained bash scripts that produce `sbo-testbuild:current` / `sbo-testbuild:15.0` docker images from NAS Slackware trees and push them to a LAN registry, consumed by `.extras/test-build`.
+
+**Architecture:** Adapt the forge `slackware/docker-images` scripts (`bootstrap.sh`, `build-full-image.sh`) into `.extras/image-builder/`. Add a third `build-sbo-testbuild.sh` layer. Share config + helpers via `config` and `lib.sh`. Pure-logic helpers are unit-tested with an assert-based self-check; the docker chain is verified manually on the VM.
+
+**Tech Stack:** bash, docker, slackpkg/installpkg (Slackware), `registry:2`.
+
+**Reference:** design spec at `.extras/docs/specs/2026-07-13-image-builder-design.md`. Forge originals cloned at (scratchpad) `docker-images/scripts/` — re-clone with `git clone slackware_forge:slackware/docker-images.git` if gone.
+
+---
+
+## File Structure
+
+```
+.extras/image-builder/
+├── config REGISTRY, MIRROR, VARIANTS, PKGDIR (sourced by all)
+├── lib.sh _log/_warn/_err, fetch(), txz_hash(), require_mount()
+├── bootstrap.sh base image FROM scratch (root; installpkg from NAS)
+├── build-full-image.sh full image FROM base (slackpkg all series)
+├── build-sbo-testbuild.sh testbuild image FROM full (installpkg sbopkg + tools)
+├── test-image-builder.sh assert-based self-check for lib.sh logic
+└── README VM-side setup checklist
+```
+
+Responsibilities:
+- **config** — the only place hostnames/paths/variants live. No script hardcodes them.
+- **lib.sh** — all shared, testable logic. Sourced by the three build scripts and by the test. No side effects at source time (only function defs + nothing else).
+- **bootstrap/full/testbuild** — one image each, thin: guard, gate, generate Dockerfile, build, push.
+- **test-image-builder.sh** — sources lib.sh, exercises fetch/txz_hash/require_mount with asserts. No docker.
+
+---
+
+## Task 1: config + lib.sh skeleton
+
+**Files:**
+- Create: `.extras/image-builder/config`
+- Create: `.extras/image-builder/lib.sh`
+
+- [ ] **Step 1: Write config**
+
+Create `.extras/image-builder/config`:
+
+```sh
+# Shared configuration for the sbo-testbuild image builder.
+# Sourced by bootstrap.sh, build-full-image.sh, build-sbo-testbuild.sh, tests.
+
+REGISTRY="docker.noland.dnx:5000"
+MIRROR="file:///mnt/nas"
+VARIANTS=(current 15.0) # x86_64 only for now
+PKGDIR="/opt/sbo-testbuild/pkgs" # sbopkg + sbo-maintainer-tools .txz
+HASH_DIR="/var/cache/sbo-testbuild" # ChangeLog hashes for rebuild gating
+```
+
+- [ ] **Step 2: Write lib.sh with logging + require_mount**
+
+Create `.extras/image-builder/lib.sh`:
+
+```sh
+# Shared helpers for the image builder. Source, do not execute.
+# Sourcing must have no side effects beyond defining functions.
+
+_log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] [${LOG_TAG:-image-builder}] $*"; }
+_warn() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] [${LOG_TAG:-image-builder}] WARNING: $*" >&2; }
+_err() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] [${LOG_TAG:-image-builder}] ERROR: $*" >&2; exit 1; }
+
+# mirror_path MIRROR RELPATH
+# Join a MIRROR (file:///... or http(s)://...) with a relative path.
+mirror_path() {
+ local mirror="$1" rel="$2"
+ printf '%s/%s' "${mirror%/}" "${rel#/}"
+}
+
+# is_local_mirror MIRROR -> 0 if file:// or bare absolute path, else 1
+is_local_mirror() {
+ case "$1" in
+ file://*) return 0 ;;
+ http://*|https://*) return 1 ;;
+ /*) return 0 ;;
+ *) return 1 ;;
+ esac
+}
+
+# strip_scheme URL -> filesystem path for a file:// or bare-local URL
+strip_scheme() {
+ local u="$1"
+ case "$u" in
+ file://*) printf '%s' "${u#file://}" ;;
+ *) printf '%s' "$u" ;;
+ esac
+}
+
+# fetch SRC DEST
+# Copy SRC (a full mirror URL) to DEST. Local mirror -> cp, http -> curl.
+# Returns non-zero on failure; caller decides whether that is fatal.
+fetch() {
+ local src="$1" dest="$2"
+ if is_local_mirror "$src"; then
+ local path; path="$(strip_scheme "$src")"
+ [[ -f "$path" ]] || return 1
+ cp "$path" "$dest"
+ else
+ curl -sSfL --connect-timeout 60 "$src" -o "$dest"
+ fi
+}
+
+# txz_hash DIR
+# Stable hash of the set of .txz files in DIR (names + contents), order
+# independent. Empty/absent dir -> empty string.
+txz_hash() {
+ local dir="$1"
+ [[ -d "$dir" ]] || { printf ''; return 0; }
+ local files; files=$(find "$dir" -maxdepth 1 -name '*.txz' -type f | sort)
+ [[ -n "$files" ]] || { printf ''; return 0; }
+ # Hash sorted "name sha256" lines so reordering does not change output.
+ while IFS= read -r f; do
+ printf '%s %s\n' "$(basename "$f")" "$(sha256sum "$f" | cut -d' ' -f1)"
+ done <<< "$files" | sha256sum | cut -d' ' -f1
+}
+
+# require_mount VERSION
+# Assert the NAS tree for VERSION is mounted (file:// mirror only).
+# x86_64 tree dir is slackware64-${VERSION}.
+require_mount() {
+ local version="$1"
+ is_local_mirror "$MIRROR" || return 0 # http mirror: nothing to check
+ local base; base="$(strip_scheme "$MIRROR")"
+ local dir="${base}/slackware64-${version}"
+ [[ -d "$dir" ]] || _err "NAS tree not mounted: ${dir}"
+}
+```
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add .extras/image-builder/config .extras/image-builder/lib.sh
+git commit -m 'image-builder: config + lib.sh helpers'
+```
+
+---
+
+## Task 2: lib.sh self-check
+
+**Files:**
+- Create: `.extras/image-builder/test-image-builder.sh`
+
+- [ ] **Step 1: Write the test**
+
+Create `.extras/image-builder/test-image-builder.sh`:
+
+```sh
+#!/bin/bash
+# Assert-based self-check for lib.sh pure logic. No docker, no network.
+# Run: bash .extras/image-builder/test-image-builder.sh
+set -u
+cd "$(dirname "$0")"
+source ./lib.sh
+
+pass=0 fail=0
+check() { # DESC EXPECTED ACTUAL
+ if [[ "$2" == "$3" ]]; then pass=$((pass+1));
+ else fail=$((fail+1)); echo "FAIL: $1"; echo " expected: [$2]"; echo " actual: [$3]"; fi
+}
+check_rc() { # DESC EXPECTED_RC ACTUAL_RC
+ if [[ "$2" == "$3" ]]; then pass=$((pass+1));
+ else fail=$((fail+1)); echo "FAIL: $1 (rc expected $2 got $3)"; fi
+}
+
+tmp="$(mktemp -d)"
+trap 'rm -rf "$tmp"' EXIT
+
+# --- mirror_path ---
+check "mirror_path trailing slash" \
+ "file:///mnt/nas/slackware64-current/PACKAGES.TXT" \
+ "$(mirror_path 'file:///mnt/nas/' 'slackware64-current/PACKAGES.TXT')"
+check "mirror_path no trailing slash" \
+ "file:///mnt/nas/x/y" "$(mirror_path 'file:///mnt/nas' '/x/y')"
+
+# --- is_local_mirror ---
+is_local_mirror "file:///mnt/nas"; check_rc "is_local file://" 0 $?
+is_local_mirror "/mnt/nas"; check_rc "is_local bare abs" 0 $?
+is_local_mirror "https://x/y"; check_rc "is_local https" 1 $?
+
+# --- strip_scheme ---
+check "strip_scheme file://" "/mnt/nas" "$(strip_scheme 'file:///mnt/nas')"
+check "strip_scheme bare" "/mnt/nas" "$(strip_scheme '/mnt/nas')"
+
+# --- fetch: local file resolves ---
+echo hello > "$tmp/src.txt"
+fetch "file://$tmp/src.txt" "$tmp/dst.txt"; check_rc "fetch local ok" 0 $?
+check "fetch local content" "hello" "$(cat "$tmp/dst.txt" 2>/dev/null)"
+
+# --- fetch: missing local file errors ---
+fetch "file://$tmp/nope.txt" "$tmp/x.txt"; check_rc "fetch local missing" 1 $?
+
+# --- txz_hash: stable + content-sensitive ---
+mkdir -p "$tmp/pkgs"
+printf a > "$tmp/pkgs/sbopkg-1.txz"
+printf b > "$tmp/pkgs/tools-1.txz"
+h1="$(txz_hash "$tmp/pkgs")"
+h2="$(txz_hash "$tmp/pkgs")"
+check "txz_hash stable" "$h1" "$h2"
+printf c > "$tmp/pkgs/tools-1.txz" # change content
+h3="$(txz_hash "$tmp/pkgs")"
+check_rc "txz_hash changes on content" 0 "$([[ "$h1" != "$h3" ]] && echo 0 || echo 1)"
+check "txz_hash empty dir" "" "$(txz_hash "$tmp/empty-nope")"
+
+# --- require_mount: missing mount errors (subshell to catch _err exit) ---
+MIRROR="file://$tmp/no-such-root"
+( require_mount current ) 2>/dev/null; check_rc "require_mount missing" 1 $?
+mkdir -p "$tmp/mnt/slackware64-current"
+MIRROR="file://$tmp/mnt"
+( require_mount current ) 2>/dev/null; check_rc "require_mount present" 0 $?
+MIRROR="https://example/x"
+( require_mount current ) 2>/dev/null; check_rc "require_mount http skips" 0 $?
+
+echo "----"
+echo "PASS: $pass FAIL: $fail"
+[[ "$fail" -eq 0 ]]
+```
+
+- [ ] **Step 2: Run — expect PASS**
+
+Run: `bash .extras/image-builder/test-image-builder.sh`
+Expected: final line `PASS: N FAIL: 0`, exit 0.
+
+If any FAIL, fix `lib.sh` (the test encodes the intended behavior).
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add .extras/image-builder/test-image-builder.sh
+git commit -m 'image-builder: lib.sh self-check'
+```
+
+---
+
+## Task 3: bootstrap.sh (base image)
+
+**Files:**
+- Create: `.extras/image-builder/bootstrap.sh`
+- Reference: forge `docker-images/scripts/bootstrap.sh`
+
+This is the forge `bootstrap.sh` adapted: source `config`+`lib.sh`, drop i586,
+`file://` fetch via `fetch()`, LAN registry, `sbo-base` name, mount guard.
+
+- [ ] **Step 1: Write bootstrap.sh**
+
+Create `.extras/image-builder/bootstrap.sh`. Start from the forge original and apply exactly these changes; everything else (the `download_pkgtxt`, `find_package`, rootfs configuration, cleanup, Dockerfile-from-scratch blocks) is copied verbatim from the forge script:
+
+1. Header block:
+
+```sh
+#!/bin/bash
+# bootstrap.sh — build the sbo-base:{ver} image FROM scratch from NAS trees.
+# Run as root (installpkg). Adapted from forge slackware/docker-images.
+set -euo pipefail
+HERE="$(cd "$(dirname "$0")" && pwd)"
+source "${HERE}/config"
+LOG_TAG=bootstrap
+source "${HERE}/lib.sh"
+
+REGISTRY_IMAGE="${REGISTRY}/sbo-base"
+[[ "${EUID}" -eq 0 ]] || _err "run as root."
+mkdir -p "${HASH_DIR}"
+```
+
+2. Argument parsing: keep `--force` and `--version` only; **remove `--arch`**
+ (x86_64 fixed). Variant list comes from config:
+
+```sh
+FORCE=false
+OPT_VERSION=""
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --version) OPT_VERSION="$2"; shift 2 ;;
+ --force) FORCE=true; shift ;;
+ *) _err "unknown argument: $1" ;;
+ esac
+done
+if [[ -n "${OPT_VERSION}" ]]; then
+ BUILD_VARIANTS=("${OPT_VERSION}")
+else
+ BUILD_VARIANTS=("${VARIANTS[@]}")
+fi
+```
+
+3. Copy the forge `PACKAGES=( ... )` array verbatim (lines 80–187 of the forge
+ original — the full base package list).
+
+4. Replace `changelog_changed`: read ChangeLog via `fetch` from the NAS,
+ x86_64-only repo key:
+
+```sh
+# changelog_changed VERSION -> 0 if changed (or --force), else 1
+changelog_changed() {
+ local version="$1"
+ local repo_key="slackware64-${version}"
+ local hash_file="${HASH_DIR}/${repo_key}.sha256"
+ local url; url="$(mirror_path "${MIRROR}" "${repo_key}/ChangeLog.txt")"
+ local tmp; tmp="$(mktemp)"
+ if ! fetch "${url}" "${tmp}"; then
+ rm -f "${tmp}"
+ _warn "${repo_key}: cannot read ChangeLog (${url}); skipping."
+ return 1
+ fi
+ local live; live=$(sha256sum "${tmp}" | cut -d' ' -f1); rm -f "${tmp}"
+ if [[ "${FORCE}" == "true" ]]; then
+ echo "${live}" > "${hash_file}"; return 0
+ fi
+ local stored=""; [[ -f "${hash_file}" ]] && stored=$(cat "${hash_file}")
+ if [[ "${live}" == "${stored}" ]]; then
+ _log "${repo_key}: ChangeLog unchanged; skipping."; return 1
+ fi
+ _log "${repo_key}: ChangeLog changed."; echo "${live}" > "${hash_file}"; return 0
+}
+```
+
+5. Copy `download_pkgtxt` and `find_package` verbatim from the forge original,
+ but change the two `curl -sfL ... "${URL}" -o "${DEST}"` download lines in
+ `download_pkgtxt` to `fetch "${URL}" "${DEST}"` (drops the http assumption).
+
+6. `build_variant`: take `VERSION` only (no ARCH). Set:
+
+```sh
+build_variant() {
+ local VERSION="$1"
+ require_mount "${VERSION}"
+ local REPO_KEY="slackware64-${VERSION}"
+ local PKG_PATH; PKG_PATH="$(mirror_path "${MIRROR}" "${REPO_KEY}")"
+ local TAG="${REGISTRY_IMAGE}:${VERSION}"
+ local SLACKPKG_MIRROR="https://slackware.nl/slackware/slackware64-${VERSION}/"
+ ...
+}
+```
+
+ The `SLACKPKG_MIRROR` written into the image stays an https slackware.nl URL
+ (it is used later by slackpkg inside the container, which has internet; the
+ NAS `file://` mount only feeds the host-side build). Keep the rest of the
+ forge `build_variant` body verbatim, except:
+ - the package **download loop** uses `fetch "${URL}" "${PKGCACHE}/${FILENAME}"`
+ instead of `curl`.
+ - the final `docker build ... && docker push` targets `${TAG}` as above.
+
+7. Main loop over `BUILD_VARIANTS`, one arg:
+
+```sh
+for VERSION in "${BUILD_VARIANTS[@]}"; do
+ if changelog_changed "${VERSION}"; then
+ build_variant "${VERSION}"
+ fi
+done
+```
+
+- [ ] **Step 2: Syntax check**
+
+Run: `bash -n .extras/image-builder/bootstrap.sh`
+Expected: no output, exit 0.
+
+- [ ] **Step 3: shellcheck (if available)**
+
+Run: `shellcheck -x .extras/image-builder/bootstrap.sh || true`
+Expected: no errors (warnings about unreachable `_err` exit are fine). If shellcheck absent, skip.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add .extras/image-builder/bootstrap.sh
+git commit -m 'image-builder: bootstrap.sh base image from NAS'
+```
+
+---
+
+## Task 4: build-full-image.sh
+
+**Files:**
+- Create: `.extras/image-builder/build-full-image.sh`
+- Reference: forge `docker-images/scripts/build-full-image.sh`
+
+Forge `build-full-image.sh` adapted: source config+lib, drop i586, LAN
+registry, `sbo-base`→`sbo-full` names.
+
+- [ ] **Step 1: Write build-full-image.sh**
+
+Create `.extras/image-builder/build-full-image.sh`. From the forge original, apply exactly:
+
+1. Header:
+
+```sh
+#!/bin/bash
+# build-full-image.sh — sbo-full:{ver} FROM sbo-base:{ver}, all series.
+# Adapted from forge slackware/docker-images. No root needed.
+set -euo pipefail
+HERE="$(cd "$(dirname "$0")" && pwd)"
+source "${HERE}/config"
+LOG_TAG=full
+source "${HERE}/lib.sh"
+
+BASE_IMAGE="${REGISTRY}/sbo-base"
+FULL_IMAGE="${REGISTRY}/sbo-full"
+DIGEST_LABEL="sbo.full.base-digest"
+```
+
+2. Arg parsing: `--force` and `--version` only (no `--arch`), variant list from
+ config — identical shape to bootstrap Task 3 Step 1.2, but `BUILD_VARIANTS`.
+
+3. `derive_tags VERSION`:
+
+```sh
+derive_tags() {
+ local VERSION="$1"
+ BASE_TAG="${BASE_IMAGE}:${VERSION}"
+ FULL_TAG="${FULL_IMAGE}:${VERSION}"
+}
+```
+
+4. Keep `needs_rebuild` verbatim from the forge original (it already takes
+ BASE_TAG/FULL_TAG args and uses `_log`/`_warn`/`FORCE`).
+
+5. `build_variant VERSION`: drop the ARCH/ARCH_SUFFIX lines; call
+ `derive_tags "${VERSION}"`. Keep the forge Dockerfile heredoc **verbatim**
+ (the `slackpkg update` + all-series install + cleanup + labels). Only the
+ `LABEL org.opencontainers.image.title/description` strings lose the
+ `(${ARCH})` suffix; leave them or hardcode `(x86_64)`.
+
+6. Main loop over `BUILD_VARIANTS`:
+
+```sh
+for VERSION in "${BUILD_VARIANTS[@]}"; do
+ build_variant "${VERSION}"
+done
+```
+
+- [ ] **Step 2: Syntax check**
+
+Run: `bash -n .extras/image-builder/build-full-image.sh`
+Expected: exit 0.
+
+- [ ] **Step 3: shellcheck (if available)**
+
+Run: `shellcheck -x .extras/image-builder/build-full-image.sh || true`
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add .extras/image-builder/build-full-image.sh
+git commit -m 'image-builder: build-full-image.sh'
+```
+
+---
+
+## Task 5: build-sbo-testbuild.sh
+
+**Files:**
+- Create: `.extras/image-builder/build-sbo-testbuild.sh`
+
+New script (no forge equivalent). FROM sbo-full, installpkg the two .txz,
+gate on full digest AND .txz-set hash.
+
+- [ ] **Step 1: Write build-sbo-testbuild.sh**
+
+Create `.extras/image-builder/build-sbo-testbuild.sh`:
+
+```sh
+#!/bin/bash
+# build-sbo-testbuild.sh — sbo-testbuild:{ver} FROM sbo-full:{ver}.
+# Adds sbopkg + sbo-maintainer-tools from prebuilt .txz in PKGDIR.
+# Rebuilds when the full image OR the .txz set changes.
+set -euo pipefail
+HERE="$(cd "$(dirname "$0")" && pwd)"
+source "${HERE}/config"
+LOG_TAG=testbuild
+source "${HERE}/lib.sh"
+
+FULL_IMAGE="${REGISTRY}/sbo-full"
+TB_IMAGE="${REGISTRY}/sbo-testbuild"
+DIGEST_LABEL="sbo.testbuild.full-digest"
+PKGS_LABEL="sbo.testbuild.pkgs-hash"
+
+FORCE=false
+OPT_VERSION=""
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --version) OPT_VERSION="$2"; shift 2 ;;
+ --force) FORCE=true; shift ;;
+ *) _err "unknown argument: $1" ;;
+ esac
+done
+if [[ -n "${OPT_VERSION}" ]]; then
+ BUILD_VARIANTS=("${OPT_VERSION}")
+else
+ BUILD_VARIANTS=("${VARIANTS[@]}")
+fi
+
+# Verify the required .txz are present before doing anything.
+require_pkgs() {
+ [[ -d "${PKGDIR}" ]] || _err "PKGDIR missing: ${PKGDIR}"
+ compgen -G "${PKGDIR}/sbopkg-*.txz" >/dev/null \
+ || _err "no sbopkg-*.txz in ${PKGDIR}"
+ compgen -G "${PKGDIR}/sbo-maintainer-tools-*.txz" >/dev/null \
+ || _err "no sbo-maintainer-tools-*.txz in ${PKGDIR}"
+}
+
+# needs_rebuild FULL_TAG TB_TAG PKGS_HASH -> 0 if rebuild needed
+needs_rebuild() {
+ local full_tag="$1" tb_tag="$2" pkgs_hash="$3"
+ [[ "${FORCE}" == "true" ]] && { _log " --force."; return 0; }
+
+ local full_digest
+ full_digest=$(docker inspect --format '{{index .RepoDigests 0}}' \
+ "${full_tag}" 2>/dev/null || echo "")
+ [[ -n "${full_digest}" ]] || { _warn " no full digest; rebuilding."; return 0; }
+
+ local rec_digest rec_pkgs
+ rec_digest=$(docker inspect \
+ --format "{{index .Config.Labels \"${DIGEST_LABEL}\"}}" \
+ "${tb_tag}" 2>/dev/null || echo "")
+ rec_pkgs=$(docker inspect \
+ --format "{{index .Config.Labels \"${PKGS_LABEL}\"}}" \
+ "${tb_tag}" 2>/dev/null || echo "")
+
+ if [[ "${full_digest}" == "${rec_digest}" && "${pkgs_hash}" == "${rec_pkgs}" ]]; then
+ _log " full image and .txz set unchanged; skipping."
+ return 1
+ fi
+ _log " full image or .txz set changed; rebuilding."
+ return 0
+}
+
+build_variant() {
+ local VERSION="$1"
+ local FULL_TAG="${FULL_IMAGE}:${VERSION}"
+ local TB_TAG="${TB_IMAGE}:${VERSION}"
+ _log "=== ${TB_TAG} ==="
+
+ _log " Pulling ${FULL_TAG}..."
+ docker pull "${FULL_TAG}"
+
+ local PKGS_HASH; PKGS_HASH="$(txz_hash "${PKGDIR}")"
+
+ if ! needs_rebuild "${FULL_TAG}" "${TB_TAG}" "${PKGS_HASH}"; then
+ return 0
+ fi
+
+ local FULL_DIGEST
+ FULL_DIGEST=$(docker inspect --format '{{index .RepoDigests 0}}' "${FULL_TAG}")
+
+ local WORKDIR; WORKDIR="$(mktemp -d /tmp/sbo-testbuild.XXXXXX)"
+ trap "rm -rf '${WORKDIR}'" RETURN
+ mkdir -p "${WORKDIR}/pkgs"
+ cp "${PKGDIR}"/sbopkg-*.txz "${PKGDIR}"/sbo-maintainer-tools-*.txz "${WORKDIR}/pkgs/"
+
+ cat > "${WORKDIR}/Dockerfile" <<DOCKERFILE
+FROM ${FULL_TAG}
+LABEL maintainer="danix <danix@danix.xyz>"
+COPY pkgs/*.txz /tmp/pkgs/
+RUN installpkg /tmp/pkgs/*.txz && rm -rf /tmp/pkgs
+RUN /sbin/ldconfig
+LABEL org.opencontainers.image.created="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
+LABEL org.opencontainers.image.title="SBo test-build env ${VERSION} (x86_64)"
+LABEL org.opencontainers.image.description="Slackware ${VERSION} full + sbopkg + sbo-maintainer-tools"
+LABEL slackware.version="${VERSION}"
+LABEL ${DIGEST_LABEL}="${FULL_DIGEST}"
+LABEL ${PKGS_LABEL}="${PKGS_HASH}"
+CMD ["/bin/bash"]
+DOCKERFILE
+
+ local BUILD_FLAGS=()
+ [[ "${FORCE}" == "true" ]] && BUILD_FLAGS+=(--no-cache)
+ _log " Building ${TB_TAG}..."
+ docker build "${BUILD_FLAGS[@]}" -t "${TB_TAG}" "${WORKDIR}"
+ _log " Pushing ${TB_TAG}..."
+ docker push "${TB_TAG}"
+ _log "=== Done: ${TB_TAG} ==="
+}
+
+require_pkgs
+for VERSION in "${BUILD_VARIANTS[@]}"; do
+ build_variant "${VERSION}"
+done
+```
+
+- [ ] **Step 2: Syntax check**
+
+Run: `bash -n .extras/image-builder/build-sbo-testbuild.sh`
+Expected: exit 0.
+
+- [ ] **Step 3: shellcheck (if available)**
+
+Run: `shellcheck -x .extras/image-builder/build-sbo-testbuild.sh || true`
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add .extras/image-builder/build-sbo-testbuild.sh
+git commit -m 'image-builder: build-sbo-testbuild.sh'
+```
+
+---
+
+## Task 6: per-variant isolation
+
+The spec requires one variant failing not to block the other, non-zero exit at
+the end. Add this to all three scripts' main loops.
+
+**Files:**
+- Modify: `.extras/image-builder/bootstrap.sh` (main loop)
+- Modify: `.extras/image-builder/build-full-image.sh` (main loop)
+- Modify: `.extras/image-builder/build-sbo-testbuild.sh` (main loop)
+
+- [ ] **Step 1: Wrap each main loop**
+
+In each script, replace the plain `for VERSION ...` main loop with an
+isolation wrapper. For **bootstrap.sh** (which also has the changelog gate):
+
+```sh
+rc=0
+for VERSION in "${BUILD_VARIANTS[@]}"; do
+ if changelog_changed "${VERSION}"; then
+ if ! ( build_variant "${VERSION}" ); then
+ _warn "variant ${VERSION} failed; continuing."
+ rc=1
+ fi
+ fi
+done
+exit "${rc}"
+```
+
+For **build-full-image.sh** and **build-sbo-testbuild.sh** (no changelog gate):
+
+```sh
+rc=0
+for VERSION in "${BUILD_VARIANTS[@]}"; do
+ if ! ( build_variant "${VERSION}" ); then
+ _warn "variant ${VERSION} failed; continuing."
+ rc=1
+ fi
+done
+exit "${rc}"
+```
+
+Note: `build_variant` runs in a subshell `( )` so a `set -e` abort inside it
+(or an `_err` exit) is contained and the loop proceeds to the next variant.
+For build-sbo-testbuild.sh keep the `require_pkgs` call **before** the loop
+(a missing .txz is fatal for all variants, not per-variant).
+
+- [ ] **Step 2: Syntax check all three**
+
+Run: `for f in bootstrap build-full-image build-sbo-testbuild; do bash -n .extras/image-builder/$f.sh; done`
+Expected: exit 0, no output.
+
+- [ ] **Step 3: Re-run lib self-check (unaffected, sanity)**
+
+Run: `bash .extras/image-builder/test-image-builder.sh`
+Expected: `FAIL: 0`.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add .extras/image-builder/bootstrap.sh .extras/image-builder/build-full-image.sh .extras/image-builder/build-sbo-testbuild.sh
+git commit -m 'image-builder: per-variant isolation, non-zero exit on failure'
+```
+
+---
+
+## Task 7: README
+
+**Files:**
+- Create: `.extras/image-builder/README`
+
+- [ ] **Step 1: Write README**
+
+Create `.extras/image-builder/README` with the VM-side setup, verbatim:
+
+```
+sbo-testbuild image builder
+===========================
+
+Builds the docker images that .extras/test-build consumes:
+ docker.noland.dnx:5000/sbo-testbuild:current
+ docker.noland.dnx:5000/sbo-testbuild:15.0
+
+Three scripts, chained (see .extras/docs/specs/2026-07-13-image-builder-design.md):
+ bootstrap.sh sbo-base:{ver} FROM scratch, base pkgs from NAS
+ build-full-image.sh sbo-full:{ver} FROM base, all series
+ build-sbo-testbuild.sh sbo-testbuild:{ver} FROM full, + sbopkg + tools
+
+All settings live in ./config.
+
+VM setup (docker.noland.dnx, Slackware x86_64, 4 vCPU / 4 GB / 80 GB)
+--------------------------------------------------------------------
+1. Install docker; enable the daemon.
+
+2. NFS-mount the two NAS trees read-only, named to match:
+ /mnt/nas/slackware64-current -> -current mirror tree
+ /mnt/nas/slackware64-15.0 -> 15.0 mirror tree
+ Each is a full mirror (PACKAGES.TXT, ChangeLog.txt, slackware64/, patches/,
+ extra/). Root must be able to read them (bootstrap runs installpkg as root).
+
+3. Run a LAN registry:
+ docker run -d --restart=always -p 5000:5000 \
+ -v /opt/registry/data:/var/lib/registry --name registry registry:2
+
+4. Mark the registry insecure (plain HTTP) on the VM AND every pulling client
+ (this dev box, the buildsystem VM). In /etc/docker/daemon.json:
+ { "insecure-registries": ["docker.noland.dnx:5000"] }
+ then restart docker.
+
+5. Drop the two prebuilt packages (built once, re-drop on upstream bumps):
+ /opt/sbo-testbuild/pkgs/sbopkg-*.txz
+ /opt/sbo-testbuild/pkgs/sbo-maintainer-tools-*.txz
+
+6. Install the nightly cron (root):
+ 0 4 * * * /path/to/.extras/image-builder/bootstrap.sh >> /var/log/sbo-testbuild.log 2>&1
+ 15 4 * * * /path/to/.extras/image-builder/build-full-image.sh >> /var/log/sbo-testbuild.log 2>&1
+ 30 4 * * * /path/to/.extras/image-builder/build-sbo-testbuild.sh >> /var/log/sbo-testbuild.log 2>&1
+
+7. Ensure docker.noland.dnx resolves on the LAN (static IP or DNS).
+
+Manual first run
+----------------
+ ./bootstrap.sh --version current --force
+ ./build-full-image.sh --version current --force
+ ./build-sbo-testbuild.sh --version current --force
+Then confirm:
+ docker pull docker.noland.dnx:5000/sbo-testbuild:current
+ docker run --rm docker.noland.dnx:5000/sbo-testbuild:current sbopkg -V
+
+Flags: --force (rebuild unconditionally), --version <current|15.0> (one variant).
+
+Tests
+-----
+ bash test-image-builder.sh # pure-logic self-check, no docker
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add .extras/image-builder/README
+git commit -m 'image-builder: README with VM setup checklist'
+```
+
+---
+
+## Task 8: executable bits + final check
+
+**Files:**
+- Modify (mode): the four `.sh` scripts
+
+- [ ] **Step 1: chmod +x the scripts**
+
+```bash
+chmod +x .extras/image-builder/bootstrap.sh \
+ .extras/image-builder/build-full-image.sh \
+ .extras/image-builder/build-sbo-testbuild.sh \
+ .extras/image-builder/test-image-builder.sh
+```
+
+- [ ] **Step 2: Run the self-check once more**
+
+Run: `bash .extras/image-builder/test-image-builder.sh`
+Expected: `PASS: N FAIL: 0`, exit 0.
+
+- [ ] **Step 3: Syntax-check all four**
+
+Run: `for f in .extras/image-builder/*.sh; do bash -n "$f" || echo "SYNTAX FAIL: $f"; done`
+Expected: no `SYNTAX FAIL` lines.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add .extras/image-builder
+git commit -m 'image-builder: mark scripts executable'
+```
+
+---
+
+## Deferred (not in this plan)
+
+- **Real end-to-end chain** on the VM (bootstrap→full→testbuild producing a
+ pushed `sbo-testbuild:current`): the user runs it once the VM/registry/mounts
+ exist, and reports back. Mirrors `.extras/test-build` Task 10.
+- **`installed_in_base`** in `.extras/test-build`: now unblockable (query the
+ built image's package db) but tracked separately in the project memory
+ `test-build-image-builder-todo`.
+- **i586** variants.
+- **Registry auth/TLS** (LAN plain HTTP for now).
+```