1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
|
#!/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.
PROJECT_VERSION="1.0.0" # bump via sed across all scripts; see CLAUDE.md Releases
# =============================================================================
# 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.
-V, --version Print version and exit.
--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 ;;
-V|--version) echo "test-build $PROJECT_VERSION"; 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 "$@"
|