aboutsummaryrefslogtreecommitdiffstats
path: root/sbo-batch-test
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-06-22 12:18:19 +0200
committerDanilo M. <danix@danix.xyz>2026-06-22 12:18:19 +0200
commite39d54ff7dcd17a3ab64c66aba6a2e9f75585485 (patch)
tree3f97a769e8a8dec2df0a0c928956d9b5ba675061 /sbo-batch-test
downloadsbo-batch-tester-e39d54ff7dcd17a3ab64c66aba6a2e9f75585485.tar.gz
sbo-batch-tester-e39d54ff7dcd17a3ab64c66aba6a2e9f75585485.zip
Initial commit: sbo-batch-test
Batch-test SlackBuilds against a clean Slackware 15.0 overlay chroot. Non-interactive, local-tree-only dependency resolution with topological sort, per-target disposable overlay, persistent per-package logs, and a color summary. Includes README.md, CLAUDE.md working notes, the reference overlay-chroot.sh, the original spec, and test-logic.sh (resolver + BLOCKED-BY-DEP self-check, 12 checks passing). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Diffstat (limited to 'sbo-batch-test')
-rwxr-xr-xsbo-batch-test668
1 files changed, 668 insertions, 0 deletions
diff --git a/sbo-batch-test b/sbo-batch-test
new file mode 100755
index 0000000..d6ca1ef
--- /dev/null
+++ b/sbo-batch-test
@@ -0,0 +1,668 @@
+#!/bin/bash
+#
+# sbo-batch-test - batch-test SlackBuilds against a clean Slackware 15.0
+# overlay chroot. Non-interactive, dependency-resolving, per-target disposable
+# overlay, persistent logs, color summary.
+#
+# Overlay/chroot mount + teardown machinery follows the patterns in
+# overlay-chroot.sh by Jeremy Hansen (bassmadrigal). The teardown ordering
+# (pts, dev/proc/sys, resolv.conf, dbus machine-id, overlay last) is preserved
+# deliberately, it is the correct unwind order.
+#
+# Runs INSIDE a Slackware64-current VM. Verifies SlackBuilds BUILD and install
+# cleanly against 15.0 userland. Does NOT test kernel modules (shares host
+# kernel). Resolution is LOCAL-tree-only, never network. Does not touch
+# slackrepo.
+#
+# No em dashes in prose by author convention.
+
+# =============================================================================
+# CONFIG (edit these for your VM)
+# =============================================================================
+
+# LOCAL (non-NFS) read-only 15.0 base install tree. This is the overlay
+# lowerdir. MUST be a local filesystem (ext4/xfs). NEVER point this at the NFS
+# mirror: overlayfs over NFS lowerdir is fragile and fails intermittently.
+SLACKWARE_BASE="/sbo-base/15.0"
+
+# NFS-mounted 15.0 mirror. Package SOURCE only, used to populate/patch
+# SLACKWARE_BASE. Read-only is fine. Never used as the overlay lowerdir.
+LOCAL_MIRROR_15="/mnt/nfs/slackware64-15.0"
+
+# One or more LOCAL SBo tree roots, resolved in order (first match wins).
+# Standard SBo layout: <root>/<category>/<prog>/{prog.SlackBuild,prog.info,...}.
+# Read in place, never copied or synced.
+SBO_TREE_ROOTS=(
+ "/home/danix/SBo-danix"
+ "/home/danix/slackbuilds-15.0"
+)
+
+# Where overlays are created. LOCAL. One disposable overlay per target lives here.
+CHROOT_LOCATION="/tmp"
+
+# Where persistent logs are written (outside the overlay, survives teardown).
+LOG_ROOT="/var/log/sbo-batch-test"
+
+# Slackware version, used for the mirror ChangeLog / patches path.
+VERSION="15.0"
+
+# =============================================================================
+# END CONFIG
+# =============================================================================
+
+set -uo pipefail
+# Note: NOT using -e globally. One package build failing is an expected,
+# handled outcome, not a script crash. Per-package execution is isolated.
+
+# ---- flags / globals --------------------------------------------------------
+USE_COLOR=1 # --no-color or non-TTY disables
+DRY_RUN=0 # resolve + print build order, do not build
+WITH_X=0 # --with-x: optional X passthrough (default headless)
+JOBS=1 # -j: reserved, see TODO
+TARGET_ARG=""
+
+RUN_DIR="" # timestamped log dir for this run
+declare -a ACTIVE_MOUNTS=() # overlay chroot roots currently mounted (for trap unwind)
+CURRENT_OVERLAY="" # the $TMPDIR of the overlay being built in now
+
+# Status tracking. Keyed by "category/prog". Parallel assoc arrays.
+declare -A ST_STATUS=() # SUCCESS|BUILD-FAILED|DOWNLOAD-FAILED|MD5-MISMATCH|INSTALL-FAILED|BLOCKED-BY-DEP|UNMET-DEP
+declare -A ST_REASON=()
+declare -A ST_TIME=() # elapsed seconds
+declare -A ST_README=() # 1 if package carries %README%
+
+# =============================================================================
+# usage
+# =============================================================================
+usage() {
+ cat <<'EOF'
+sbo-batch-test - batch-test SlackBuilds against a clean Slackware 15.0 overlay chroot
+
+USAGE:
+ sbo-batch-test [OPTIONS] <program-name>
+ sbo-batch-test [OPTIONS] <category-folder>
+
+MODES:
+ <program-name> Resolve its full SBo dependency tree, build+install every
+ dep in topological order, then build+install the target.
+ <category-folder> Treat every SlackBuild dir inside as an independent target.
+ Each target gets its OWN fresh overlay against pristine 15.0.
+
+OPTIONS:
+ -h, --help This text.
+ --no-color Disable ANSI color (auto-disabled when stdout is not a TTY).
+ --dry-run Resolve and print the build order, do not build.
+ --with-x Enable X passthrough via xhost +local:hosts (security caveat:
+ allows local non-network connections to your X server).
+ Default is headless (no X).
+ -j, --jobs N Reserved. Currently a no-op stub (builds are serial).
+
+EXTENSION POINTS (not implemented, see TODOs in source):
+ - "all" mode (build every package across all SBo roots).
+ - queue/list-file mode (build a named list of targets).
+ - -j parallelism.
+
+EOF
+}
+
+# =============================================================================
+# color helpers
+# =============================================================================
+init_color() {
+ if [[ $USE_COLOR -eq 1 && -t 1 ]]; then
+ C_RED=$'\e[31m'; C_GRN=$'\e[32m'; C_YEL=$'\e[33m'; C_RST=$'\e[0m'
+ else
+ C_RED=""; C_GRN=""; C_YEL=""; C_RST=""
+ fi
+}
+
+# =============================================================================
+# arg parsing
+# =============================================================================
+parse_args() {
+ while [[ $# -gt 0 ]]; do
+ case "$1" in
+ -h|--help) usage; exit 0 ;;
+ --no-color) USE_COLOR=0; shift ;;
+ --dry-run) DRY_RUN=1; shift ;;
+ --with-x) WITH_X=1; shift ;;
+ -j|--jobs) JOBS="${2:-1}"; shift 2 ;; # TODO: implement parallelism
+ -*) echo "Unknown option: $1" >&2; usage >&2; exit 2 ;;
+ *)
+ if [[ -n "$TARGET_ARG" ]]; then
+ echo "Only one target argument accepted (got '$TARGET_ARG' and '$1')." >&2
+ exit 2
+ fi
+ TARGET_ARG="$1"; shift ;;
+ esac
+ done
+ if [[ -z "$TARGET_ARG" ]]; then
+ echo "No target given." >&2; usage >&2; exit 2
+ fi
+}
+
+# =============================================================================
+# startup validation (fail fast, copy-pasteable hints)
+# =============================================================================
+validate_env() {
+ if [[ $EUID -ne 0 ]]; then
+ echo "This tool must run as root (overlay + chroot)." >&2
+ exit 1
+ fi
+
+ # SLACKWARE_BASE: local, looks like a real Slackware install.
+ if [[ ! -d "$SLACKWARE_BASE" || ! -d "$SLACKWARE_BASE/var/log/packages" ]]; then
+ cat >&2 <<EOF
+SLACKWARE_BASE is missing or incomplete: $SLACKWARE_BASE
+It must be a LOCAL (non-NFS) full Slackware 15.0 install tree.
+Populate it from the mirror with the FULL package set (not minimal,
+a minimal base causes false "missing dependency" results):
+
+ mkdir -p "$SLACKWARE_BASE"
+ for p in "$LOCAL_MIRROR_15"/slackware64/*/*.t?z; do
+ installpkg --root "$SLACKWARE_BASE" "\$p"
+ done
+EOF
+ exit 1
+ fi
+ # Guard against the classic mistake: base sitting on the NFS mirror path.
+ case "$SLACKWARE_BASE" in
+ "$LOCAL_MIRROR_15"*)
+ echo "SLACKWARE_BASE must NOT live under LOCAL_MIRROR_15 (NFS). overlayfs over NFS lowerdir is unsupported here." >&2
+ exit 1 ;;
+ esac
+
+ # LOCAL_MIRROR_15: NFS, must be mounted/reachable.
+ if [[ ! -d "$LOCAL_MIRROR_15" || ! -f "$LOCAL_MIRROR_15/ChangeLog.txt" ]]; then
+ echo "LOCAL_MIRROR_15 not reachable (NFS mount absent?): $LOCAL_MIRROR_15" >&2
+ echo "Expected a Slackware 15.0 mirror with ChangeLog.txt at its root." >&2
+ exit 1
+ fi
+
+ # SBo roots exist locally.
+ local r found=0
+ for r in "${SBO_TREE_ROOTS[@]}"; do
+ if [[ -d "$r" ]]; then found=1; else
+ echo "SBO_TREE_ROOTS path does not exist: $r" >&2
+ fi
+ done
+ if [[ $found -eq 0 ]]; then
+ echo "No valid SBo tree root found. Fix SBO_TREE_ROOTS." >&2
+ exit 1
+ fi
+}
+
+# =============================================================================
+# keep base patched from the mirror (reuse reference logic, point at NFS mirror,
+# write to local base). Skipped on --dry-run.
+# =============================================================================
+update_base() {
+ local marker="$SLACKWARE_BASE/last-base-update"
+ touch "$marker"
+ local head_now; head_now="$(head -n1 "$LOCAL_MIRROR_15/ChangeLog.txt")"
+ if [[ "$head_now" == "$(cat "$marker")" ]]; then
+ echo "Base is up-to-date with the mirror."
+ return
+ fi
+ echo "Patching base from mirror..."
+ local p
+ for p in "$LOCAL_MIRROR_15"/patches/packages/*.t?z; do
+ [[ -e "$p" ]] || continue
+ if [[ ! -e "$SLACKWARE_BASE/var/lib/pkgtools/packages/$(basename "${p%.*}")" ]]; then
+ ROOT="$SLACKWARE_BASE" upgradepkg --install-new "$p"
+ fi
+ done
+ echo "$head_now" > "$marker"
+ echo "Base patched."
+}
+
+# =============================================================================
+# SBo tree lookup. Find the SlackBuild dir for a prog name across roots.
+# Echoes the dir path, returns 0 if found, 1 otherwise.
+# =============================================================================
+find_slackbuild_dir() {
+ local prog="$1" root d
+ for root in "${SBO_TREE_ROOTS[@]}"; do
+ [[ -d "$root" ]] || continue
+ # <root>/<category>/<prog>
+ for d in "$root"/*/"$prog"; do
+ if [[ -d "$d" && -f "$d/$prog.info" ]]; then
+ echo "$d"; return 0
+ fi
+ done
+ done
+ return 1
+}
+
+# Category of a SlackBuild dir = its parent dir name.
+category_of() { basename "$(dirname "$1")"; }
+# Key used in status maps and log filenames.
+pkg_key() { echo "$(category_of "$1")/$(basename "$1")"; }
+
+# Read REQUIRES from a .info, stripped. Echoes space-separated tokens.
+read_requires() {
+ local info="$1"
+ # shellcheck disable=SC1090
+ ( set +u; source "$info"; echo "${REQUIRES:-}" )
+}
+
+# =============================================================================
+# DEPENDENCY RESOLUTION
+# Builds a topological order over the local SBo tree.
+# - transitive REQUIRES
+# - %README% recorded, not built
+# - deps already in base (installed in chroot base) are satisfied, not built
+# - deps neither in SBo tree nor installed => UNMET-DEP
+# - cycles detected and reported, no infinite loop
+#
+# Outputs the order into the global array RESOLVED_ORDER (dir paths).
+# Records unmet deps into UNMET (prog -> requiring pkg) and cycle errors.
+# Returns 1 if any hard resolution failure (unmet dep, cycle) for the target set.
+# =============================================================================
+declare -a RESOLVED_ORDER=()
+declare -A UNMET=() # prog -> "needed by X"
+declare -a CYCLES=() # human-readable cycle descriptions
+declare -A HAS_README=() # dir -> 1 if its REQUIRES carried %README%
+
+# Is a prog already installed in the base tree? (stock 15.0 or otherwise present)
+installed_in_base() {
+ local prog="$1"
+ # Package db entries look like prog-version-arch-build[ tag]
+ ls "$SLACKWARE_BASE"/var/log/packages/ 2>/dev/null \
+ | grep -qE "^${prog}-[^-]+-[^-]+-[^-]+"
+}
+
+# DFS topo sort with cycle detection.
+# visit_state: dir -> 0 visiting (on stack), 1 done
+declare -A _vstate=()
+_resolve_visit() {
+ local dir="$1" parent="$2"
+ local key; key="$(basename "$dir")"
+
+ if [[ "${_vstate[$dir]:-}" == "1" ]]; then return 0; fi
+ if [[ "${_vstate[$dir]:-}" == "0" ]]; then
+ CYCLES+=("cycle involving $key (pulled in via $parent)")
+ return 1
+ fi
+ _vstate["$dir"]=0
+
+ local info="$dir/$(basename "$dir").info"
+ local req tok depdir rc=0
+ req="$(read_requires "$info")"
+ for tok in $req; do
+ if [[ "$tok" == "%README%" ]]; then
+ HAS_README["$dir"]=1
+ continue
+ fi
+ if depdir="$(find_slackbuild_dir "$tok")"; then
+ _resolve_visit "$depdir" "$key" || rc=1
+ elif installed_in_base "$tok"; then
+ : # satisfied by base, nothing to build
+ else
+ UNMET["$tok"]="needed by $key"
+ rc=1
+ fi
+ done
+
+ _vstate["$dir"]=1
+ RESOLVED_ORDER+=("$dir")
+ return $rc
+}
+
+# Resolve a single target dir into RESOLVED_ORDER (deps first, target last).
+resolve_target() {
+ local dir="$1"
+ RESOLVED_ORDER=()
+ CYCLES=()
+ UNMET=()
+ _vstate=()
+ _resolve_visit "$dir" "(top)"
+}
+
+# =============================================================================
+# OVERLAY LIFECYCLE (per target). Patterns from overlay-chroot.sh.
+# setup_overlay -> echoes the chroot root path, sets CURRENT_OVERLAY.
+# teardown_overlay <tmpdir> -> idempotent unwind in correct reverse order.
+# =============================================================================
+setup_overlay() {
+ local tmpdir; tmpdir="$(mktemp -d "$CHROOT_LOCATION"/sbo-bt.XXXXXX)"
+ mkdir "$tmpdir"/{changes,tmp,chroot}
+
+ # overlayfs: read-only 15.0 base as lowerdir, disposable upper on top.
+ mount -t overlay overlay \
+ -olowerdir="$SLACKWARE_BASE",upperdir="$tmpdir/changes",workdir="$tmpdir/tmp" \
+ "$tmpdir/chroot"
+
+ # bind system dirs
+ mkdir -p "$tmpdir"/changes/{dev,proc,sys}
+ local i
+ for i in dev proc sys; do
+ mount -o bind "/$i" "$tmpdir/chroot/$i"
+ done
+ # /dev/pts (sudo/pty)
+ mkdir -p "$tmpdir"/changes/dev/pts
+ mount -o bind /dev/pts "$tmpdir/chroot/dev/pts"
+ # internet
+ mount -o bind /etc/resolv.conf "$tmpdir/chroot/etc/resolv.conf"
+ chroot "$tmpdir/chroot" /bin/bash -c "/usr/sbin/update-ca-certificates --fresh >/dev/null 2>&1" || true
+ # dbus machine-id
+ touch "$tmpdir/chroot/var/lib/dbus/machine-id"
+ mount -o bind /var/lib/dbus/machine-id "$tmpdir/chroot/var/lib/dbus/machine-id"
+
+ CURRENT_OVERLAY="$tmpdir"
+ ACTIVE_MOUNTS+=("$tmpdir")
+ echo "$tmpdir"
+}
+
+# Idempotent teardown. Safe to call twice, safe mid-abort.
+teardown_overlay() {
+ local tmpdir="$1"
+ [[ -n "$tmpdir" && -d "$tmpdir" ]] || return 0
+ local c="$tmpdir/chroot"
+
+ mountpoint -q "$c/dev/pts" && umount "$c/dev/pts"
+ local i
+ for i in dev proc sys; do
+ mountpoint -q "$c/$i" && umount "$c/$i"
+ done
+ mountpoint -q "$c/etc/resolv.conf" && umount "$c/etc/resolv.conf"
+ mountpoint -q "$c/var/lib/dbus/machine-id" && umount "$c/var/lib/dbus/machine-id"
+ mountpoint -q "$c" && umount "$c"
+
+ # Remove from ACTIVE_MOUNTS
+ local n=() m
+ for m in "${ACTIVE_MOUNTS[@]}"; do [[ "$m" != "$tmpdir" ]] && n+=("$m"); done
+ ACTIVE_MOUNTS=("${n[@]:-}")
+
+ rm -rf "$tmpdir"
+}
+
+# Trap: unwind every live overlay on abort. Reverse order of creation.
+cleanup_trap() {
+ [[ $WITH_X -eq 1 ]] && xhost -local:hosts >/dev/null 2>&1 || true
+ local i
+ for (( i=${#ACTIVE_MOUNTS[@]}-1; i>=0; i-- )); do
+ teardown_overlay "${ACTIVE_MOUNTS[$i]}"
+ done
+}
+trap cleanup_trap EXIT INT TERM
+
+# =============================================================================
+# BUILD + INSTALL one package inside an existing overlay chroot.
+# Args: <chroot-tmpdir> <slackbuild-dir>
+# Sets ST_STATUS / ST_REASON / ST_TIME for the package key.
+# Returns 0 on SUCCESS, 1 otherwise.
+# =============================================================================
+build_one() {
+ local tmpdir="$1" dir="$2"
+ local c="$tmpdir/chroot"
+ local prog cat key
+ prog="$(basename "$dir")"; cat="$(category_of "$dir")"; key="$cat/$prog"
+ local logf="$RUN_DIR/${cat}_${prog}.log"
+ local start; start=$(date +%s)
+
+ # 1. copy SlackBuild dir into the overlay
+ local workroot="/sbo-work"
+ mkdir -p "$c$workroot"
+ cp -a "$dir" "$c$workroot/$prog"
+
+ # %README% reminder carries through to summary
+ [[ "${HAS_README[$dir]:-}" == "1" ]] && ST_README["$key"]=1
+
+ # 2-5. download, md5, build, install all run INSIDE the chroot non-interactively.
+ # The heredoc script writes a status token to a known file we read back out.
+ # overlayfs note: if a build fails ONLY here and works on bare 15.0, suspect
+ # an overlayfs sharp edge (rename/whiteout quirks) rather than a real build bug.
+ local statf="$c$workroot/$prog.status"
+ chroot "$c" /bin/bash -s <<CHROOT_EOF >>"$logf" 2>&1
+set -uo pipefail
+cd "$workroot/$prog" || { echo "BUILD-FAILED: cannot cd"; echo BUILD-FAILED > "$workroot/$prog.status"; exit 1; }
+. ./$prog.info
+
+# Log resolved build context so the overlay never needs to be kept.
+echo "===== sbo-batch-test: $prog ====="
+echo "PRGNAM=\${PRGNAM:-$prog} VERSION=\${VERSION:-?} BUILD=\${BUILD:-?} TAG=\${TAG:-?}"
+echo "uname -m: \$(uname -m) OUTPUT=\${OUTPUT:-/tmp}"
+echo "REQUIRES=\${REQUIRES:-}"
+echo "================================="
+
+# pick arch-specific download/md5 if present (x86_64 VM)
+if [ "\$(uname -m)" = "x86_64" ] && [ -n "\${DOWNLOAD_x86_64:-}" ] && [ "\${DOWNLOAD_x86_64}" != "UNSUPPORTED" ] && [ "\${DOWNLOAD_x86_64}" != "UNTESTED" ]; then
+ DL="\$DOWNLOAD_x86_64"; MD="\$MD5SUM_x86_64"
+else
+ DL="\$DOWNLOAD"; MD="\$MD5SUM"
+fi
+
+# 2. download
+for u in \$DL; do
+ wget -c --tries=3 "\$u" || { echo DOWNLOAD-FAILED > "$workroot/$prog.status"; exit 1; }
+done
+
+# 3. verify md5
+set -- \$MD
+for u in \$DL; do
+ f="\$(basename "\$u")"
+ want="\$1"; shift
+ got="\$(md5sum "\$f" | cut -d' ' -f1)"
+ if [ "\$got" != "\$want" ]; then
+ echo "MD5 mismatch on \$f: want \$want got \$got"
+ echo MD5-MISMATCH > "$workroot/$prog.status"; exit 1
+ fi
+done
+
+# 4. build non-interactively
+chmod +x ./$prog.SlackBuild
+if ! ./$prog.SlackBuild; then
+ echo BUILD-FAILED > "$workroot/$prog.status"; exit 1
+fi
+
+# 5. installpkg the resulting package (SlackBuilds write to \${OUTPUT:-/tmp})
+out="\${OUTPUT:-/tmp}"
+pkg="\$(ls -t "\$out"/${prog}-*.t?z 2>/dev/null | head -n1)"
+if [ -z "\$pkg" ]; then
+ echo "No package produced in \$out"
+ echo BUILD-FAILED > "$workroot/$prog.status"; exit 1
+fi
+if ! installpkg "\$pkg"; then
+ echo INSTALL-FAILED > "$workroot/$prog.status"; exit 1
+fi
+# Log the installed file list (from the package db) so the overlay is disposable.
+echo "===== installed files: \$(basename "\$pkg") ====="
+pkgname="\$(basename "\$pkg")"; pkgname="\${pkgname%.t?z}"
+cat "/var/log/packages/\$pkgname" 2>/dev/null || echo "(package db entry not found: \$pkgname)"
+echo "================================="
+echo SUCCESS > "$workroot/$prog.status"
+CHROOT_EOF
+
+ local status="BUILD-FAILED"
+ [[ -f "$statf" ]] && status="$(cat "$statf")"
+ local end; end=$(date +%s)
+ ST_TIME["$key"]=$(( end - start ))
+ ST_STATUS["$key"]="$status"
+ [[ "$status" == "SUCCESS" ]] && return 0
+ ST_REASON["$key"]="see $(basename "$logf")"
+ return 1
+}
+
+# =============================================================================
+# Run one target: fresh overlay, build its resolved chain, teardown.
+# Args: <target-slackbuild-dir>
+# =============================================================================
+run_target() {
+ local target_dir="$1"
+ local tkey; tkey="$(pkg_key "$target_dir")"
+
+ echo
+ echo "=== Target: $tkey ==="
+ resolve_target "$target_dir"
+ local resolve_rc=$?
+
+ # Hard resolution failures: mark target and (newly seen) deps, skip building.
+ if [[ ${#CYCLES[@]} -gt 0 || ${#UNMET[@]} -gt 0 ]]; then
+ local why=""
+ if [[ ${#UNMET[@]} -gt 0 ]]; then
+ local u
+ for u in "${!UNMET[@]}"; do why+="unmet:$u(${UNMET[$u]}) "; done
+ fi
+ [[ ${#CYCLES[@]} -gt 0 ]] && why+="${CYCLES[*]}"
+ ST_STATUS["$tkey"]="UNMET-DEP"
+ ST_REASON["$tkey"]="$why"
+ echo " resolution failed: $why"
+ return
+ fi
+
+ if [[ $DRY_RUN -eq 1 ]]; then
+ echo " build order:"
+ local d
+ for d in "${RESOLVED_ORDER[@]}"; do
+ echo " $(pkg_key "$d")$([[ "${HAS_README[$d]:-}" == 1 ]] && echo " [%README%]")"
+ echo "$(pkg_key "$d")" >> "$RUN_DIR/build-order.txt"
+ done
+ return
+ fi
+
+ echo " build order: ${RESOLVED_ORDER[*]##*/}"
+ for d in "${RESOLVED_ORDER[@]}"; do echo "$(pkg_key "$d")" >> "$RUN_DIR/build-order.txt"; done
+
+ local tmpdir; tmpdir="$(setup_overlay)"
+
+ # Build chain in order. On a failure, mark dependents BLOCKED-BY-DEP.
+ local d failed_progs=()
+ for d in "${RESOLVED_ORDER[@]}"; do
+ local key; key="$(pkg_key "$d")"
+ local prog; prog="$(basename "$d")"
+
+ # already blocked because an earlier dep it needs failed?
+ if depends_on_failed "$d" failed_progs; then
+ ST_STATUS["$key"]="BLOCKED-BY-DEP"
+ ST_REASON["$key"]="blocked by failed dep"
+ [[ "${HAS_README[$d]:-}" == "1" ]] && ST_README["$key"]=1
+ echo " $key: BLOCKED-BY-DEP"
+ failed_progs+=("$prog")
+ continue
+ fi
+
+ echo " building $key ..."
+ if build_one "$tmpdir" "$d"; then
+ echo " $key: ${ST_STATUS[$key]} (${ST_TIME[$key]}s)"
+ else
+ echo " $key: ${ST_STATUS[$key]} (${ST_TIME[$key]}s)"
+ failed_progs+=("$prog")
+ fi
+ done
+
+ # Overlay is always torn down: the per-package logs capture everything worth
+ # inspecting (full build/install output, resolved .info env, installed file
+ # list), so there is no reason to retain the filesystem.
+ teardown_overlay "$tmpdir"
+}
+
+# Does SlackBuild dir $1 directly require any prog in failed list (nameref $2)?
+# ponytail: direct REQUIRES check only; transitive blocking still works because
+# this runs in topo order, so a dep's failure propagates up one hop at a time.
+depends_on_failed() {
+ local dir="$1"; local -n failed="$2"
+ local info="$dir/$(basename "$dir").info"
+ local req tok f
+ req="$(read_requires "$info")"
+ for tok in $req; do
+ [[ "$tok" == "%README%" ]] && continue
+ for f in "${failed[@]:-}"; do
+ [[ "$tok" == "$f" ]] && return 0
+ done
+ done
+ return 1
+}
+
+# =============================================================================
+# SUMMARY (screen color + plain summary.log)
+# =============================================================================
+print_summary() {
+ local total=$SECONDS
+ local succ=0 fail=0 blocked=0
+ local summary="$RUN_DIR/summary.log"
+
+ {
+ echo "sbo-batch-test run summary"
+ echo "target: $TARGET_ARG"
+ echo
+ } > "$summary"
+
+ echo
+ echo "================ SUMMARY ================"
+ local key
+ for key in "${!ST_STATUS[@]}"; do
+ local st="${ST_STATUS[$key]}" rsn="${ST_REASON[$key]:-}" t="${ST_TIME[$key]:-0}"
+ local rd=""; [[ "${ST_README[$key]:-}" == "1" ]] && rd=" [%README%]"
+ local col="$C_YEL"
+ case "$st" in
+ SUCCESS) col="$C_GRN"; ((succ++)) ;;
+ BLOCKED-BY-DEP|UNMET-DEP) col="$C_YEL"; ((blocked++)) ;;
+ *) col="$C_RED"; ((fail++)) ;;
+ esac
+ printf "%s%-30s %-16s%s %s%s (%ss)\n" "$col" "$key" "$st" "$C_RST" "$rsn" "$rd" "$t"
+ printf "%-30s %-16s %s%s (%ss)\n" "$key" "$st" "$rsn" "$rd" "$t" >> "$summary"
+ done
+ echo "----------------------------------------"
+ printf "%s%d succeeded%s, %s%d failed%s, %s%d blocked%s, total %ss\n" \
+ "$C_GRN" "$succ" "$C_RST" "$C_RED" "$fail" "$C_RST" "$C_YEL" "$blocked" "$C_RST" "$total"
+ echo "logs: $RUN_DIR"
+ {
+ echo
+ echo "$succ succeeded, $fail failed, $blocked blocked, total ${total}s"
+ echo "logs: $RUN_DIR"
+ } >> "$summary"
+}
+
+# =============================================================================
+# main
+# =============================================================================
+main() {
+ parse_args "$@"
+ init_color
+ validate_env
+
+ RUN_DIR="$LOG_ROOT/$(date +%Y-%m-%d_%H-%M-%S)"
+ mkdir -p "$RUN_DIR"
+ : > "$RUN_DIR/build-order.txt"
+
+ [[ $DRY_RUN -eq 0 ]] && update_base
+
+ [[ $WITH_X -eq 1 ]] && { echo "X passthrough on (xhost +local:hosts). Security: allows local connections to your X server."; xhost +local:hosts >/dev/null; }
+
+ # Collect targets.
+ local -a targets=()
+ if [[ -d "$TARGET_ARG" ]]; then
+ # category-folder mode: every subdir with a matching .info is a target
+ local d prog
+ for d in "$TARGET_ARG"/*; do
+ [[ -d "$d" ]] || continue
+ prog="$(basename "$d")"
+ [[ -f "$d/$prog.info" ]] && targets+=("$d")
+ done
+ if [[ ${#targets[@]} -eq 0 ]]; then
+ echo "No SlackBuild targets found in folder: $TARGET_ARG" >&2
+ exit 1
+ fi
+ else
+ # single-package mode: resolve the name in the tree
+ local tdir
+ if tdir="$(find_slackbuild_dir "$TARGET_ARG")"; then
+ targets+=("$tdir")
+ else
+ echo "Program not found in any SBo tree root: $TARGET_ARG" >&2
+ exit 1
+ fi
+ fi
+
+ # TODO: "all" mode and queue/list-file mode plug in here (populate `targets`).
+
+ local t
+ for t in "${targets[@]}"; do
+ run_target "$t"
+ done
+
+ print_summary
+}
+
+main "$@"