aboutsummaryrefslogtreecommitdiffstats
path: root/test-build
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 /test-build
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 'test-build')
-rwxr-xr-xtest-build876
1 files changed, 876 insertions, 0 deletions
diff --git a/test-build b/test-build
new file mode 100755
index 0000000..eb4009f
--- /dev/null
+++ b/test-build
@@ -0,0 +1,876 @@
+#!/bin/bash
+#
+# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 2 as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+#
+# test-build - verify an already-published SBo package still builds on a target
+# Slackware version inside a throwaway docker container. Resolves + builds its
+# SBo deps from the local tree, caches built deps per image digest, reports
+# per-package status and lints the target.
+#
+# Dependency-resolution, cache, and summary logic are adapted from sbo-batch-test
+# (github: danixland). The overlay chroot is replaced by a docker container: the
+# container IS the disposable environment, so no overlayfs.
+#
+# No em dashes in prose by author convention.
+
+# =============================================================================
+# CONFIG (do not edit here; real values live in the external config file)
+# =============================================================================
+SBO_TREE_CURRENT=""
+SBO_TREE_STABLE=""
+IMAGE_CURRENT=""
+IMAGE_STABLE=""
+LOG_ROOT="/var/log/sbo-test-build"
+PKG_CACHE=""
+
+TB_CONFIG="${TB_CONFIG:-$HOME/.config/sbo-testbuild/config}"
+if [[ -f "$TB_CONFIG" ]]; then
+ # shellcheck disable=SC1090
+ source "$TB_CONFIG"
+fi
+
+TB_OVERRIDES="${TB_OVERRIDES:-$HOME/.config/sbo-testbuild/overrides}"
+
+# =============================================================================
+set -uo pipefail
+# Not -e: a package build failing is a handled outcome, not a script crash.
+
+# ---- flags / globals --------------------------------------------------------
+USE_COLOR=1 # --no-color or non-TTY disables
+DRY_RUN=0 # --dry-run: resolve + print order, do not build
+ASSUME_YES=0 # --yes: skip the confirm prompt (still prints the order)
+USE_CACHE=1 # --no-cache disables the dep cache for one run
+KEEP_TARGET=0 # --keep: copy the built target package out to KEEP_DIR
+VERSION_ID="current" # "current" | "15.0"; set by --stable
+TARGET_ARG=""
+
+ACTIVE_TREE="" # selected SBo tree (by version)
+ACTIVE_IMAGE="" # selected image tag (by version)
+RUN_DIR="" # timestamped log dir for this run
+DEPS_DIR="" # per-run host dir of built dep .txz, mounted into the container
+BUILD_OUT="" # per-run host dir where built packages are copied out
+
+# Status tracking. Keyed by "category/prog". Parallel assoc arrays.
+declare -A ST_STATUS=()
+declare -A ST_REASON=()
+declare -A ST_TIME=()
+declare -A ST_README=()
+
+usage() {
+ cat <<'EOF'
+test-build - verify an SBo package builds on a target Slackware in docker
+
+USAGE:
+ test-build [OPTIONS] <program-name>
+
+OPTIONS:
+ -h, --help This text.
+ --stable Target Slackware 15.0 (image + tree). Default is -current.
+ --dry-run Resolve, apply overrides, print the build order, do not build.
+ --yes Skip the confirm prompt (the order is still printed first).
+ --no-cache Rebuild all deps this run, ignore/refresh the cache.
+ --keep Copy the built target package out to a kept/ dir (path is
+ printed), so it can be installed on the host. The build is
+ otherwise throwaway.
+ --no-color Disable ANSI color (auto-disabled when stdout is not a TTY).
+EOF
+}
+
+parse_args() {
+ while [[ $# -gt 0 ]]; do
+ case "$1" in
+ -h|--help) usage; exit 0 ;;
+ --stable|15.0) VERSION_ID="15.0"; shift ;;
+ --dry-run) DRY_RUN=1; shift ;;
+ --yes) ASSUME_YES=1; shift ;;
+ --no-cache) USE_CACHE=0; shift ;;
+ --keep) KEEP_TARGET=1; shift ;;
+ --no-color) USE_COLOR=0; shift ;;
+ -*) echo "Unknown option: $1" >&2; usage >&2; exit 2 ;;
+ *)
+ if [[ -n "$TARGET_ARG" ]]; then
+ echo "Only one target 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
+}
+
+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
+}
+
+# Map VERSION_ID to the active tree + image. No I/O, unit-testable.
+select_version_paths() {
+ if [[ "$VERSION_ID" == "15.0" ]]; then
+ ACTIVE_TREE="$SBO_TREE_STABLE"; ACTIVE_IMAGE="$IMAGE_STABLE"
+ else
+ ACTIVE_TREE="$SBO_TREE_CURRENT"; ACTIVE_IMAGE="$IMAGE_CURRENT"
+ fi
+}
+
+# require_config: the external config must exist and set the version's paths.
+require_config() {
+ if [[ ! -f "$TB_CONFIG" ]]; then
+ cat >&2 <<EOF
+No config file: $TB_CONFIG
+Copy the example and edit it:
+
+ mkdir -p "\$(dirname "$TB_CONFIG")"
+ cp test-build-config.example "$TB_CONFIG"
+ \${EDITOR:-vi} "$TB_CONFIG"
+EOF
+ exit 1
+ fi
+ select_version_paths
+ if [[ -z "$ACTIVE_TREE" || -z "$ACTIVE_IMAGE" ]]; then
+ echo "Config $TB_CONFIG is missing the tree/image for version '$VERSION_ID'." >&2
+ exit 1
+ fi
+ if [[ ! -d "$ACTIVE_TREE" ]]; then
+ echo "SBo tree for '$VERSION_ID' does not exist: $ACTIVE_TREE" >&2
+ exit 1
+ fi
+}
+
+# =============================================================================
+# SBo tree lookup
+# =============================================================================
+# In this tool there is one active tree per run, but the resolver reads an array
+# named SBO_TREE_ROOTS so the ported logic and its self-check match sbo-batch-test.
+# main() sets SBO_TREE_ROOTS=("$ACTIVE_TREE") after require_config.
+declare -a SBO_TREE_ROOTS=()
+
+find_slackbuild_dir() {
+ local prog="$1" root d
+ for root in "${SBO_TREE_ROOTS[@]}"; do
+ [[ -d "$root" ]] || continue
+ 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() { basename "$(dirname "$1")"; }
+pkg_key() { echo "$(category_of "$1")/$(basename "$1")"; }
+
+# Resolve the TARGET to test from CWD or a path (repo-agnostic). The target is
+# the SlackBuild you are editing, wherever it lives; its deps come from the
+# configured SBo tree (see find_slackbuild_dir), NOT the other way around.
+# - a path (absolute, or containing '/', or '.'/'./x'): use that dir directly
+# - a bare name: ./<name>/ under CWD, else CWD itself if it IS <name>/
+# Prints the resolved absolute dir on success; returns 1 (with a message) if the
+# dir has no matching <name>.info.
+resolve_target_dir() {
+ local arg="$1" dir prog
+ if [[ "$arg" == /* || "$arg" == .* || "$arg" == */* ]]; then
+ dir="${arg%/}"; prog="$(basename "$dir")"
+ elif [[ -f "./$arg/$arg.info" ]]; then
+ dir="./$arg"; prog="$arg"
+ elif [[ "$(basename "$PWD")" == "$arg" && -f "./$arg.info" ]]; then
+ dir="."; prog="$arg"
+ else
+ echo "Target '$arg' not found: no ./$arg/$arg.info, and CWD is not $arg/." >&2
+ return 1
+ fi
+ if [[ ! -f "$dir/$prog.info" ]]; then
+ echo "Target dir '$dir' has no $prog.info (expected a SlackBuild package dir)." >&2
+ return 1
+ fi
+ ( cd "$dir" && pwd ) # emit absolute path
+}
+
+read_requires() {
+ local info="$1"
+ # shellcheck disable=SC1090
+ ( set +u; source "$info"; echo "${REQUIRES:-}" )
+}
+
+version_of() {
+ local dir="$1"
+ local info="$dir/$(basename "$dir").info"
+ [[ -f "$info" ]] || return
+ local v; v="$(grep -m1 '^VERSION=' "$info" | cut -d'"' -f2)"
+ echo "$v"
+}
+
+# =============================================================================
+# DEPENDENCY RESOLUTION (topo sort + cycle detection, LOCAL tree only)
+# =============================================================================
+declare -a RESOLVED_ORDER=()
+declare -A UNMET=()
+declare -a CYCLES=()
+declare -A HAS_README=()
+declare -A _vstate=()
+declare -A FETCH_DEPS=() # prog -> 1: resolve via sbopkg in-container
+
+# Is a prog already present in the container base? Kept as a callback so the
+# pure topo logic stays testable.
+#
+# Populated lazily from the active image's package db on first call: each entry
+# in /var/log/packages is named <name>-<ver>-<arch>-<build>, so strip the last
+# three dash-fields to get the bare package name and match the SBo token
+# against that. TB_BASE_PKGS can be pre-seeded by tests to bypass docker.
+declare -A TB_BASE_PKGS=()
+TB_BASE_LOADED="${TB_BASE_LOADED:-0}"
+
+load_base_pkgs() {
+ [[ "$TB_BASE_LOADED" == "1" ]] && return 0
+ TB_BASE_LOADED=1
+ [[ -n "$ACTIVE_IMAGE" ]] || return 0
+ local entry name
+ while IFS= read -r entry; do
+ [[ -n "$entry" ]] || continue
+ # strip trailing -<ver>-<arch>-<build>
+ name="${entry%-*-*-*}"
+ TB_BASE_PKGS["$name"]=1
+ done < <(docker run --rm "$ACTIVE_IMAGE" \
+ /bin/sh -c 'ls /var/log/packages' 2>/dev/null)
+}
+
+installed_in_base() {
+ load_base_pkgs
+ [[ -n "${TB_BASE_PKGS[$1]:-}" ]]
+}
+
+_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
+ tok="$(apply_rename "$tok")"
+ # drop: satisfied by the -current base (e.g. a 15.0-only dep like rust-opt
+ # whose payload ships in current's full-install image). Skip it entirely:
+ # no recurse, no UNMET, no order entry. Inert on 15.0 (overrides are).
+ if [[ "${OV_DROP[$tok]:-}" == "1" ]]; then
+ continue
+ fi
+ if [[ "${OV_FETCH[$tok]:-}" == "1" ]]; then
+ FETCH_DEPS["$tok"]=1
+ continue
+ fi
+ if depdir="$(find_slackbuild_dir "$tok")"; then
+ _resolve_visit "$depdir" "$key" || rc=1
+ elif installed_in_base "$tok"; then
+ :
+ else
+ UNMET["$tok"]="needed by $key"
+ rc=1
+ fi
+ done
+
+ _vstate["$dir"]=1
+ RESOLVED_ORDER+=("$dir")
+ return $rc
+}
+
+resolve_target() {
+ local dir="$1"
+ RESOLVED_ORDER=()
+ CYCLES=()
+ UNMET=()
+ FETCH_DEPS=()
+ _vstate=()
+ _resolve_visit "$dir" "(top)"
+}
+
+# =============================================================================
+# current-vs-stable overrides. Parsed from $TB_OVERRIDES. Applied only when
+# targeting -current (15.0 is the SBo baseline, no deltas).
+# =============================================================================
+declare -A OV_DROP=() # prog -> 1
+declare -A OV_RENAME=() # old -> new
+declare -A OV_FETCH=() # prog -> 1
+
+load_overrides() {
+ OV_DROP=(); OV_RENAME=(); OV_FETCH=()
+ # 15.0 is the baseline: no overrides.
+ [[ "$VERSION_ID" == "15.0" ]] && return
+ [[ -f "$TB_OVERRIDES" ]] || return
+ local line kind rest
+ while IFS= read -r line; do
+ line="${line%%#*}" # strip comments
+ line="${line#"${line%%[![:space:]]*}"}" # ltrim
+ [[ -z "$line" ]] && continue
+ kind="${line%%:*}"; rest="${line#*:}"
+ kind="${kind//[[:space:]]/}"
+ rest="${rest#"${rest%%[![:space:]]*}"}" # ltrim value
+ case "$kind" in
+ drop) OV_DROP["${rest//[[:space:]]/}"]=1 ;;
+ fetch) OV_FETCH["${rest//[[:space:]]/}"]=1 ;;
+ rename)
+ # rest is "old -> new"
+ local old new
+ old="${rest%%->*}"; new="${rest##*->}"
+ old="${old//[[:space:]]/}"; new="${new//[[:space:]]/}"
+ [[ -n "$old" && -n "$new" ]] && OV_RENAME["$old"]="$new" ;;
+ *) echo "WARN: unknown override rule: $line" >&2 ;;
+ esac
+ done < "$TB_OVERRIDES"
+}
+
+# Map a dep token through rename rules (identity if no rule).
+apply_rename() {
+ local tok="$1"
+ echo "${OV_RENAME[$tok]:-$tok}"
+}
+
+# Remove dropped packages from RESOLVED_ORDER in place.
+apply_overrides_to_order() {
+ local d prog keep=()
+ for d in "${RESOLVED_ORDER[@]}"; do
+ prog="$(basename "$d")"
+ [[ "${OV_DROP[$prog]:-}" == "1" ]] && continue
+ keep+=("$d")
+ done
+ # Assign without "${keep[@]:-}": under set -u that fallback yields a
+ # one-element array holding an empty string when keep is empty (all deps
+ # dropped), which the build loop would then iterate over as d="". Guard it.
+ if [[ ${#keep[@]} -eq 0 ]]; then
+ RESOLVED_ORDER=()
+ else
+ RESOLVED_ORDER=("${keep[@]}")
+ fi
+}
+
+# =============================================================================
+# Dependency cache. Layout: $CACHE_ROOT/<cat>/<prog>/<prog>-<ver>-...txz where
+# CACHE_ROOT = $PKG_CACHE/<image-digest> (set per run by resolve_cache_root).
+# Key is prog+version. --no-cache (USE_CACHE=0) or empty PKG_CACHE disables.
+# =============================================================================
+CACHE_ROOT="" # set by resolve_cache_root once the image digest is known
+
+# True when the cache is usable this run.
+_cache_on() { [[ $USE_CACHE -eq 1 && -n "$PKG_CACHE" && -n "$CACHE_ROOT" ]]; }
+
+_cache_ver_of() {
+ local prog="$1" base="$2"
+ base="${base#"$prog"-}"
+ echo "${base%%-*}"
+}
+
+# cache_decision <cat> <prog> <version> -> cached | bump:OLD:NEW | new
+cache_decision() {
+ local cat="$1" prog="$2" version="$3"
+ _cache_on || { echo new; return; }
+ local dir="$CACHE_ROOT/$cat/$prog"
+ local f newest=""
+ for f in "$dir/$prog"-*.t?z; do
+ [[ -e "$f" ]] || continue
+ [[ -z "$newest" || "$f" -nt "$newest" ]] && newest="$f"
+ done
+ [[ -z "$newest" ]] && { echo new; return; }
+ local have; have="$(_cache_ver_of "$prog" "$(basename "$newest")")"
+ if [[ "$have" == "$version" ]]; then echo cached; else echo "bump:$have:$version"; fi
+}
+
+cache_path() {
+ local cat="$1" prog="$2" version="$3"
+ _cache_on || return
+ local dir="$CACHE_ROOT/$cat/$prog"
+ local f newest=""
+ for f in "$dir/$prog"-*.t?z; do
+ [[ -e "$f" ]] || continue
+ [[ -z "$newest" || "$f" -nt "$newest" ]] && newest="$f"
+ done
+ [[ -z "$newest" ]] && return
+ [[ "$(_cache_ver_of "$prog" "$(basename "$newest")")" == "$version" ]] && echo "$newest"
+}
+
+cache_store() {
+ local cat="$1" prog="$2" src="$3"
+ _cache_on || return
+ local dir="$CACHE_ROOT/$cat/$prog"
+ mkdir -p "$dir"
+ rm -f "$dir"/*.t?z
+ cp -a "$src" "$dir/"
+}
+
+cache_label() {
+ local dir="$1" is_target="$2"
+ local cat prog ver dec
+ cat="$(category_of "$dir")"; prog="$(basename "$dir")"; ver="$(version_of "$dir")"
+ dec="$(cache_decision "$cat" "$prog" "$ver")"
+ local label
+ case "$dec" in
+ cached) label="cached ($ver)" ;;
+ bump:*) label="rebuild: ${dec#bump:}"; label="${label/:/ -> }" ;;
+ *) label="build (new)" ;;
+ esac
+ if [[ "$is_target" == "1" ]]; then
+ case "$dec" in
+ cached) label="build (cached $ver, rebuilt as target)" ;;
+ esac
+ echo "target, $label"
+ else
+ echo "$label"
+ fi
+}
+
+# Compute CACHE_ROOT from the image's digest, namespaced by variant. Falls back
+# to the tag if the digest cannot be read (still isolates per image reference).
+# The dir is "<variant>-<digest>", so an image rebuild changes the digest and
+# self-invalidates the cache (deps built against the old base are never reused).
+#
+# Prune stale digests of THIS variant on the way in: a base update leaves the
+# old <variant>-<olddigest> dir orphaned, so drop it. Scoped to the variant so a
+# --current run does not wipe the 15.0 cache (both live caches survive, and
+# testing both trees back-to-back still reuses built deps).
+resolve_cache_root() {
+ [[ -z "$PKG_CACHE" ]] && { CACHE_ROOT=""; return; }
+ local digest
+ digest="$(docker image inspect --format '{{index .Id}}' "$ACTIVE_IMAGE" 2>/dev/null)"
+ [[ -z "$digest" ]] && digest="tag-${ACTIVE_IMAGE//[^a-zA-Z0-9._-]/_}"
+ digest="${digest//[^a-zA-Z0-9._-]/_}"
+ local name="${VERSION_ID}-${digest}"
+ CACHE_ROOT="$PKG_CACHE/$name"
+ mkdir -p "$CACHE_ROOT"
+
+ # drop superseded caches for this variant only
+ local d
+ for d in "$PKG_CACHE/${VERSION_ID}-"*; do
+ [[ -d "$d" ]] || continue
+ [[ "$(basename "$d")" == "$name" ]] && continue
+ rm -rf "$d"
+ done
+}
+
+# Does SlackBuild dir $1 directly require any prog in the dead list (nameref $2)?
+# Direct-requires check only; transitive blocking works because run_target
+# iterates in topo order, propagating a failure one hop per package.
+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
+}
+
+# build_one <slackbuild-dir> <is_target 0|1> [container-name]
+# Runs the build in a throwaway container. Sets ST_STATUS/ST_REASON/ST_TIME.
+# Successful builds copy their package to a host workdir; deps are cached and
+# the target is linted. Returns 0 on SUCCESS/CACHED, 1 otherwise.
+build_one() {
+ local dir="$1" is_target="${2:-0}"
+ 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)
+ local version; version="$(version_of "$dir")"
+
+ [[ "${HAS_README[$dir]:-}" == "1" ]] && ST_README["$key"]=1
+
+ # Dep with a version-matching cached package: installpkg it into the shared
+ # dep-package dir; no build. The target never takes this path.
+ if [[ "$is_target" != "1" ]]; then
+ local cached; cached="$(cache_path "$cat" "$prog" "$version")"
+ if [[ -n "$cached" ]]; then
+ cp -a "$cached" "$DEPS_DIR/"
+ {
+ echo "===== test-build: $prog (from cache) ====="
+ echo "cached package: $(basename "$cached")"
+ } >> "$logf"
+ ST_TIME["$key"]=$(( $(date +%s) - start ))
+ ST_STATUS["$key"]="CACHED"
+ return 0
+ fi
+ fi
+
+ # Build in a container. Mounts:
+ # $dir -> /sbo/pkg (ro, the SlackBuild)
+ # $DEPS_DIR -> /sbo/deps (rw, already-built dep .txz to installpkg first)
+ # $BUILD_OUT -> /sbo/out (rw, where the built package is copied out)
+ # The in-container script installs any deps present, then builds the target,
+ # writes a status token to /sbo/out/$prog.status, and copies the package out.
+ local statf="$BUILD_OUT/$prog.status"
+ rm -f "$statf"
+
+ # fetch deps (removed from -current tree): let the container's sbopkg build
+ # them first. FETCH_DEPS is the set collected during resolution.
+ local fetch_list=""
+ local fp
+ for fp in "${!FETCH_DEPS[@]}"; do fetch_list+="$fp "; done
+
+ # -i is required: without it docker does not attach stdin, so `bash -s` reads
+ # nothing and the heredoc script is silently discarded (exit 0, empty log).
+ docker run --rm -i \
+ -v "$dir":/sbo/pkg:ro \
+ -v "$DEPS_DIR":/sbo/deps \
+ -v "$BUILD_OUT":/sbo/out \
+ -e PROG="$prog" \
+ -e FETCH_LIST="$fetch_list" \
+ -e IS_TARGET="$is_target" \
+ "$ACTIVE_IMAGE" /bin/bash -s >>"$logf" 2>&1 <<'CONTAINER_EOF'
+set -uo pipefail
+prog="$PROG"
+statf="/sbo/out/$prog.status"
+
+# 0. install already-built dependency packages (order guaranteed by the host).
+for d in /sbo/deps/*.t?z; do
+ [[ -e "$d" ]] || continue
+ installpkg --terse "$d" || { echo "INSTALL-FAILED (dep $d)"; echo INSTALL-FAILED > "$statf"; exit 1; }
+done
+
+# 0b. fetch-from-SBo deps via sbopkg (removed from the -current tree).
+for f in $FETCH_LIST; do
+ echo "sbopkg-building fetch dep: $f"
+ sbopkg -B -i "$f" || { echo "BUILD-FAILED (fetch dep $f)"; echo BUILD-FAILED > "$statf"; exit 1; }
+done
+
+# copy the SlackBuild out of the read-only mount so it can write there.
+cp -a /sbo/pkg /sbo/build
+cd /sbo/build || { echo BUILD-FAILED > "$statf"; exit 1; }
+. ./"$prog".info
+
+export OUTPUT=/sbo/out
+mkdir -p "$OUTPUT"
+
+echo "===== test-build: $prog ====="
+echo "PRGNAM=${PRGNAM:-$prog} VERSION=${VERSION:-?} BUILD=${BUILD:-?} TAG=${TAG:-?}"
+echo "uname -m: $(uname -m) OUTPUT=$OUTPUT"
+echo "REQUIRES=${REQUIRES:-}"
+echo "================================="
+
+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
+
+for u in $DL; do
+ wget -c --tries=3 "$u" || { echo DOWNLOAD-FAILED > "$statf"; exit 1; }
+done
+
+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 > "$statf"; exit 1
+ fi
+done
+
+# Source /etc/profile.d so dep-provided env is live (google-go-lang sets GOROOT
+# + PATH to its go here, rust-opt sets cargo, etc.). This heredoc runs in a
+# non-login shell, which does NOT read profile.d, so a bare `go build` would
+# otherwise pick the system gccgo instead of the installed google-go-lang.
+set +u # profile.d scripts routinely reference unset vars
+for pf in /etc/profile.d/*.sh; do
+ [ -r "$pf" ] && . "$pf"
+done
+set -u
+
+chmod +x ./"$prog".SlackBuild
+if ! ./"$prog".SlackBuild; then
+ echo BUILD-FAILED > "$statf"; exit 1
+fi
+
+pkg="$(ls -t "$OUTPUT"/"$prog"-*.t?z 2>/dev/null | head -n1)"
+if [ -z "$pkg" ]; then
+ echo "No package produced in $OUTPUT"
+ echo BUILD-FAILED > "$statf"; exit 1
+fi
+if ! installpkg --terse "$pkg"; then
+ echo INSTALL-FAILED > "$statf"; exit 1
+fi
+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)"
+echo "================================="
+
+# lint the target here in the container: sbopkglint is baked into the image and
+# runs as root, so it needs no host sudo. Fail-soft: findings never fail the build.
+if [ "${IS_TARGET:-0}" = "1" ] && command -v sbopkglint >/dev/null 2>&1; then
+ echo "===== sbopkglint: $(basename "$pkg") ====="
+ if sbopkglint "$pkg"; then
+ echo "LINT-CLEAN"
+ else
+ echo "LINT-FINDINGS"
+ fi
+ echo "================================="
+fi
+echo SUCCESS > "$statf"
+CONTAINER_EOF
+
+ local status="BUILD-FAILED"
+ [[ -f "$statf" ]] && status="$(cat "$statf")"
+ ST_TIME["$key"]=$(( $(date +%s) - start ))
+ ST_STATUS["$key"]="$status"
+
+ if [[ "$status" == "SUCCESS" ]]; then
+ # locate the built package copied to the host workdir
+ local built newest=""
+ for built in "$BUILD_OUT/${prog}"-*.t?z; do
+ [[ -e "$built" ]] || continue
+ [[ -z "$newest" || "$built" -nt "$newest" ]] && newest="$built"
+ done
+ if [[ -n "$newest" ]]; then
+ if [[ "$is_target" != "1" ]]; then
+ cache_store "$cat" "$prog" "$newest"
+ # make the dep available to later builds in this run
+ cp -a "$newest" "$DEPS_DIR/"
+ else
+ # lint ran in-container (see IS_TARGET block); surface its verdict from
+ # the log so the host summary can show clean/findings.
+ if grep -q '^LINT-FINDINGS$' "$logf" 2>/dev/null; then
+ echo " lint: ${C_RED}findings${C_RST} (see $(basename "$logf"))"
+ elif grep -q '^LINT-CLEAN$' "$logf" 2>/dev/null; then
+ echo " lint: ${C_GRN}clean${C_RST}"
+ fi
+ # --keep: copy the built target package to a durable dir (sibling of the
+ # logs tree, not inside a throwaway run dir) so it can be installed on
+ # the host, e.g. to regenerate post-install artifacts.
+ if [[ $KEEP_TARGET -eq 1 ]]; then
+ local keepdir; keepdir="$(dirname "$LOG_ROOT")/kept"
+ mkdir -p "$keepdir"
+ cp -a "$newest" "$keepdir/"
+ echo " kept: $keepdir/$(basename "$newest")"
+ fi
+ fi
+ fi
+ return 0
+ fi
+ ST_REASON["$key"]="see $(basename "$logf")"
+ return 1
+}
+
+# confirm_order: print the resolved order (overrides marked) and ask to proceed.
+# --yes skips the prompt but the order is still printed. --dry-run never reaches
+# here. Returns 0 to proceed, 1 to abort.
+confirm_order() {
+ local target_dir="$1"
+ echo " build order (${VERSION_ID}):"
+ local d
+ for d in "${RESOLVED_ORDER[@]}"; do
+ local it=0; [[ "$d" == "$target_dir" ]] && it=1
+ local rdm=""; [[ "${HAS_README[$d]:-}" == 1 ]] && rdm=" [%README%]"
+ printf " %-30s %s%s\n" "$(pkg_key "$d")" "$(cache_label "$d" "$it")" "$rdm"
+ echo "$(pkg_key "$d")" >> "$RUN_DIR/build-order.txt"
+ done
+ # note fetch deps (built via sbopkg in-container, not in the order list)
+ local fp
+ for fp in "${!FETCH_DEPS[@]}"; do
+ printf " %-30s %s\n" "$fp" "fetch (sbopkg in container)"
+ done
+ [[ $ASSUME_YES -eq 1 ]] && return 0
+ local reply
+ read -rp " Proceed? [Y/n] " reply
+ [[ -z "$reply" || "$reply" =~ ^[Yy]$ ]]
+}
+
+# run_target <target-slackbuild-dir>
+run_target() {
+ local target_dir="$1"
+ local tkey; tkey="$(pkg_key "$target_dir")"
+
+ echo
+ echo "=== Target: $tkey (${VERSION_ID}) ==="
+ resolve_target "$target_dir"
+ apply_overrides_to_order
+
+ # Hard resolution failures: report and stop, do not build.
+ 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"
+ echo " add an override rule ($TB_OVERRIDES) and rerun, or fix the tree." >&2
+ return
+ fi
+
+ if [[ $DRY_RUN -eq 1 ]]; then
+ echo " build order (dry-run):"
+ local d
+ for d in "${RESOLVED_ORDER[@]}"; do
+ local it=0; [[ "$d" == "$target_dir" ]] && it=1
+ local rdm=""; [[ "${HAS_README[$d]:-}" == 1 ]] && rdm=" [%README%]"
+ printf " %-30s %s%s\n" "$(pkg_key "$d")" "$(cache_label "$d" "$it")" "$rdm"
+ echo "$(pkg_key "$d")" >> "$RUN_DIR/build-order.txt"
+ done
+ local fp
+ for fp in "${!FETCH_DEPS[@]}"; do
+ printf " %-30s %s\n" "$fp" "fetch (sbopkg in container)"
+ done
+ return
+ fi
+
+ if ! confirm_order "$target_dir"; then
+ echo " aborted."
+ ST_STATUS["$tkey"]="ABORTED"
+ return
+ fi
+
+ # Per-run docker workdirs (host side, discarded after the run).
+ DEPS_DIR="$RUN_DIR/deps"; BUILD_OUT="$RUN_DIR/out"
+ mkdir -p "$DEPS_DIR" "$BUILD_OUT"
+
+ local d failed_progs=()
+ for d in "${RESOLVED_ORDER[@]}"; do
+ local key; key="$(pkg_key "$d")"
+ local prog; prog="$(basename "$d")"
+
+ 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
+
+ local it=0; [[ "$d" == "$target_dir" ]] && it=1
+ echo " building $key ..."
+ if build_one "$d" "$it"; 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
+}
+
+# =============================================================================
+# SUMMARY (ported)
+# =============================================================================
+print_summary() {
+ local total=$SECONDS
+ local succ=0 fail=0 blocked=0 cached=0
+ local summary="$RUN_DIR/summary.log"
+
+ {
+ echo "test-build run summary"
+ echo "target: $TARGET_ARG version: $VERSION_ID"
+ 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++)) ;;
+ CACHED) col="$C_GRN"; ((cached++)) ;;
+ BLOCKED-BY-DEP|UNMET-DEP|ABORTED) 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, %s%d cached%s, total %ss\n" \
+ "$C_GRN" "$succ" "$C_RST" "$C_RED" "$fail" "$C_RST" "$C_YEL" "$blocked" "$C_RST" \
+ "$C_GRN" "$cached" "$C_RST" "$total"
+ echo "logs: $RUN_DIR"
+ if [[ $fail -eq 0 && $blocked -eq 0 ]]; then
+ echo "${C_GRN}All green.${C_RST} Safe to build the SBo submission tarball on the host."
+ fi
+ {
+ echo
+ echo "$succ succeeded, $fail failed, $blocked blocked, $cached cached, total ${total}s"
+ echo "logs: $RUN_DIR"
+ } >> "$summary"
+}
+
+
+# Ensure the selected image is available locally. It is built by a separate job
+# (the image-builder) and pushed to the LAN registry; this script only consumes
+# it. If it is not already local, pull it once (ACTIVE_IMAGE is a fully-qualified
+# registry ref). A stale local tag is not refreshed here.
+# ponytail: pull-if-missing only. If the registry's :current is rebuilt, the
+# local copy goes stale silently. Add a --pull force-flag if that bites.
+require_image() {
+ docker image inspect "$ACTIVE_IMAGE" >/dev/null 2>&1 && return 0
+ echo "Image not present locally, pulling: $ACTIVE_IMAGE" >&2
+ docker pull "$ACTIVE_IMAGE" >&2 && return 0
+ cat >&2 <<EOF
+Image not found and pull failed: $ACTIVE_IMAGE
+It is produced by the image-builder job (full Slackware $VERSION_ID +
+sbo-maintainer-tools + sbopkg) and pushed to the LAN registry. Check the
+registry is reachable and the tag exists, then rerun. This script does not
+build images.
+EOF
+ exit 1
+}
+
+main() {
+ parse_args "$@"
+ init_color
+ require_config
+ SBO_TREE_ROOTS=("$ACTIVE_TREE")
+ load_overrides
+
+ # image + cache namespace (skip the image check on a pure dry-run so the order
+ # can be inspected without the image present).
+ if [[ $DRY_RUN -eq 0 ]]; then
+ command -v docker >/dev/null 2>&1 || { echo "docker not found in PATH." >&2; exit 1; }
+ require_image
+ fi
+ resolve_cache_root
+
+ RUN_DIR="$LOG_ROOT/$(date +%Y-%m-%d_%H-%M-%S)"
+ mkdir -p "$RUN_DIR"
+ : > "$RUN_DIR/build-order.txt"
+
+ # The target is the local SlackBuild under test (CWD or a path); its deps are
+ # resolved from the configured SBo tree during _resolve_visit.
+ local tdir
+ if ! tdir="$(resolve_target_dir "$TARGET_ARG")"; then
+ exit 1
+ fi
+
+ run_target "$tdir"
+ print_summary
+}
+
+main "$@"