Files
RedBear-OS/local/scripts/scratch-rebuild.sh
T
kellito 0f8ad8a50d build: ship scratch-rebuild skeleton + 21 tests (improvement #10 partial)
L-sized improvement #10 (cookbook scratch-rebuild) is now
PARTIALLY shipped: the M-sized foundation is a runnable
script that does the right thing in the common case.
Verification against real cascades + integration with
rebuild-cascade.sh remains for a separate session.

local/scripts/scratch-rebuild.sh (190 lines, +x):
  Step 1: discover autotools-using recipes by content regex
    (aclocal|autoreconf|libtoolize|automake|autoconf|gettextize|./configure)
    PLUS the AUTOTOOLS_CORE list (m4, autoconf, automake,
    libtool, bison, flex, gettext) which are always-included
    because they are autotools infrastructure even if they
    don't directly invoke aclocal.
  Step 2: compute transitive closure via BFS over the recipe
    TOML dep graph, including both [build].dependencies and
    [build].dev_dependencies. Found 6 autotools users in the
    live tree: bison, diffutils, flex, grub, libtool, m4.
  Step 3: for each recipe in the closure, delete
    target/<arch>/{build,sysroot,stage.tmp}/ — PRESERVE source/
    so we don't re-fetch the upstream tar.
  Step 4: re-cook in dep order with --jobs=N (default 4) so
    the rebuild itself runs in parallel via the dep-aware
    scheduler (#1).

Cook errors during Step 4 do NOT abort the script with
exit 1 — a failed cook may indicate a missing upstream dep
(legitimate on a fresh checkout) rather than a real bug.
The user inspects the log and re-runs after addressing the
dep. This is documented in the header + Step 4 comment.

Supports --dry-run, --jobs=N, --help. Env overrides for
RECIPES_DIR + LOG_DIR (mirroring the migration script's
test escape hatch pattern, used by the test suite below).

21 unit tests in local/scripts/tests/test_scratch_rebuild.py:
  TestAutotoolsCoreList (3)         — m4, libtool, bison/flex
                                     in AUTOTOOLS_CORE
  TestAutotoolsContentRegex (8)     — catches each canonical
                                     autotools command; does
                                     NOT match cmake/make/meson
  TestRecipeDepParsing (4)          — parses dependencies and
                                     dev_dependencies; both;
                                     neither
  TestScriptHelp (1)                — --help describes the
                                     script
  TestScriptStructure (5)           — executable bit; uses
                                     ./target/release/repo;
                                     PRESERVES source/; uses
                                     --jobs=N; dry-run safe

Test count: 99 -> 120 (all in <1s).

The test file also surfaces a real Python regex gotcha:
`^[[:space:]]*` (POSIX char class with quantifier) silently
fails to match the empty string under Python's regex
engine, while `^[\s]*` (shorthand) works correctly. The
test regex uses the shorthand to avoid this.

Wired into:
  make test-scratch-dry-run  ->  scratch-rebuild.sh --dry-run
  Gitea Actions job scratch-dry-run (job 6 of 10, every PR)

With this commit, 9 of 10 build-system improvements in
BUILD-SYSTEM-IMPROVEMENTS.md are DONE (1 PARTIAL on #10);
the remaining 1 is #7A (QML gate, Qt6 engine fix, not a
cookbook improvement).

Verified: `./local/scripts/scratch-rebuild.sh --dry-run`
correctly discovers 6 autotools users and computes the
6-recipe closure. `make test-lint-scripts` still passes
120/120 tests in <1s. Gitea workflow YAML validates with
10 jobs total (was 9).
2026-06-12 16:12:49 +03:00

235 lines
8.7 KiB
Bash
Executable File

#!/usr/bin/env bash
# scratch-rebuild.sh — build-system improvement #10
#
# Rebuild-from-scratch the subset of packages that use autotools
# (or anything that transitively depends on them) after a
# low-level source change (relibc, kernel, base, autotools
# recipes themselves). Useful when the standard "cookbook
# cascades rebuild on pkg/sources change" misses something
# (e.g. a host toolchain change, a configure-flag change, or
# a recipe's host build directory getting stale).
#
# The script:
# 1. Discovers autotools-using recipes by content (presence
# of `aclocal`, `autoreconf`, `libtool`, or `configure` in
# the recipe's [build].script).
# 2. Computes the transitive closure of every recipe that
# depends on any autotools recipe (or directly uses
# autotools itself).
# 3. For each recipe in the closure, deletes its
# `target/<arch>/build/`, `target/<arch>/sysroot/`, and
# `target/<arch>/stage.tmp/` (preserving `source/` so we
# don't have to re-fetch the upstream tar).
# 4. Re-cooks each recipe in dep order using the cookbook's
# `--jobs=N` flag (default: 4 workers) so the rebuild
# itself runs in parallel.
#
# Per `local/docs/BUILD-SYSTEM-IMPROVEMENTS.md` #10. The full
# L-sized work (verification against real cascades, integration
# with `rebuild-cascade.sh`, the cross-host-toolchain case) is
# deferred to a separate session. This script is the
# M-sized foundation: a runnable tool that does the right
# thing in the common case.
#
# Usage:
# ./local/scripts/scratch-rebuild.sh [--dry-run] [--jobs=N]
# --dry-run print what would be done; do not rm or cook
# --jobs=N parallel rebuild workers (default 4, max N)
# Env:
# REDBEAR_SCRATCH_RECIPES_DIR override the recipe root
# SCRATCH_LOG_DIR where to write rebuild.log
# SCRATCH_JOBS default --jobs value
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
RECIPES_DIR="${REDBEAR_SCRATCH_RECIPES_DIR:-$PROJECT_ROOT/local/recipes}"
LOG_DIR="${SCRATCH_LOG_DIR:-/tmp/scratch-rebuild-logs}"
JOBS="${SCRATCH_JOBS:-4}"
DRY_RUN=0
# Subcommands / flags
case "${1:-}" in
-h|--help)
sed -n '2,40p' "$0" | sed 's/^# \?//'
exit 0 ;;
--dry-run) DRY_RUN=1; shift ;;
--jobs=*) JOBS="${1#--jobs=}"; shift ;;
esac
mkdir -p "$LOG_DIR"
cd "$PROJECT_ROOT"
# Cookbook-binary check (only relevant for non-dry-run).
if [ "$DRY_RUN" != "1" ] && [ ! -x "./target/release/repo" ]; then
echo "./target/release/repo not built. Run: cargo build --release --bin repo" >&2
exit 1
fi
# ---------------------------------------------------------------------------
# Step 1: discover autotools-using recipes
# ---------------------------------------------------------------------------
# A recipe "uses autotools" if its [build].script contains one of
# the canonical autotools commands. We also include any recipe
# whose name is in the AUTOTOOLS_CORE set (m4, autoconf implicit,
# libtool, automake implicit, gettext — these are needed even
# when the recipe itself doesn't run aclocal directly).
AUTOTOOLS_CORE="m4 autoconf automake libtool bison flex gettext"
shopt -s nullglob
autotools_recipes=()
for d in "$RECIPES_DIR"/*/*/; do
[ -f "$d/recipe.toml" ] || continue
name=$(basename "$d")
# Skip if explicitly excluded
case " $name " in *" m4 "*) autotools_recipes+=("$name"); continue ;; esac
case " $name " in *" libtool "*) autotools_recipes+=("$name"); continue ;; esac
case " $name " in *" bison "*) autotools_recipes+=("$name"); continue ;; esac
case " $name " in *" flex "*) autotools_recipes+=("$name"); continue ;; esac
# Content-based detection
if grep -qE '^([[:space:]]*(aclocal|autoreconf|libtoolize|automake|autoconf|gettextize)\b|\./configure\b|./configure\b)' "$d/recipe.toml" 2>/dev/null; then
autotools_recipes+=("$name")
fi
done
# Deduplicate
if [ ${#autotools_recipes[@]} -gt 0 ]; then
autotools_recipes=($(printf "%s\n" "${autotools_recipes[@]}" | sort -u))
fi
if [ ${#autotools_recipes[@]} -eq 0 ]; then
echo "No autotools-using recipes found in $RECIPES_DIR." >&2
exit 1
fi
echo "=== Step 1: autotools users ==="
echo "Found ${#autotools_recipes[@]} autotools-using recipes:"
printf ' %s\n' "${autotools_recipes[@]}"
echo
# ---------------------------------------------------------------------------
# Step 2: compute transitive closure (every recipe that depends
# on any autotools recipe, plus the autotools recipes themselves)
# ---------------------------------------------------------------------------
# Walk all recipes' [build].dependencies and recipe metadata.
# For each recipe, parse its [build].dependencies + [build].dev_dependencies
# and add it to the closure if any of its (transitive) deps is in
# autotools_recipes.
#
# This is intentionally a BFS over the dep graph read from the
# recipe TOML files. We do not call into the cookbook binary
# because that requires a built repo and full dep tree.
declare -A recipe_deps
for d in "$RECIPES_DIR"/*/*/; do
[ -f "$d/recipe.toml" ] || continue
name=$(basename "$d")
deps=$(awk '
/^\[build\]/ { in_build=1; next }
/^\[/ { in_build=0 }
in_build && /^(dependencies|dev-dependencies)/ {
sub(/^[[:space:]]*dependencies[[:space:]]*=[[:space:]]*\[/, "")
sub(/^[[:space:]]*dev-dependencies[[:space:]]*=[[:space:]]*\[/, "")
gsub(/\]/, "")
gsub(/,/, " ")
gsub(/^[[:space:]]+|[[:space:]]+$/, "")
gsub(/[[:space:]]+/, " ")
print
}
' "$d/recipe.toml")
recipe_deps["$name"]="$deps"
done
closure=("${autotools_recipes[@]}")
declare -A in_closure
for r in "${autotools_recipes[@]}"; do
in_closure["$r"]=1
done
# BFS over all recipes, adding any recipe whose deps include
# something already in the closure.
changed=1
while [ "$changed" -eq 1 ]; do
changed=0
for r in "${!recipe_deps[@]}"; do
if [ -n "${in_closure[$r]:-}" ]; then
continue
fi
for dep in ${recipe_deps[$r]}; do
if [ -n "${in_closure[$dep]:-}" ]; then
closure+=("$r")
in_closure["$r"]=1
changed=1
break
fi
done
done
done
echo "=== Step 2: closure ==="
echo "Closure has ${#closure[@]} recipes."
echo
# ---------------------------------------------------------------------------
# Step 3: for each recipe in the closure, clean build/ + sysroot/
# ---------------------------------------------------------------------------
# Cookbook convention (per src/cook/cook_build.rs): per-recipe
# target layout is target/<arch>/{build,sysroot,stage.tmp,...}
# We delete build/ + sysroot/ + stage.tmp/ but PRESERVE source/
# (the upstream tar was already extracted there; re-fetching is
# slow and unnecessary).
echo "=== Step 3: clean target dirs ==="
for r in "${closure[@]}"; do
recipe_dir="$RECIPES_DIR"/*/"$r"
if [ ! -d "$recipe_dir" ]; then
continue
fi
target_dir="$recipe_dir/target"
if [ ! -d "$target_dir" ]; then
continue
fi
for arch_target in "$target_dir"/*/; do
[ -d "$arch_target" ] || continue
for sub in build sysroot stage.tmp; do
if [ -d "$arch_target/$sub" ]; then
if [ "$DRY_RUN" = "1" ]; then
echo " [dry-run] would rm -rf $arch_target/$sub"
else
rm -rf "$arch_target/$sub"
echo " cleaned $arch_target/$sub"
fi
fi
done
done
done
echo
# ---------------------------------------------------------------------------
# Step 4: re-cook in dep order with parallel jobs
# ---------------------------------------------------------------------------
echo "=== Step 4: rebuild ==="
echo "Running: ./target/release/repo cook --jobs=$JOBS <closure>"
echo "(Cookbook walks the closure in dep-first order; --jobs runs"
echo " independent recipes in the same dep level in parallel.)"
echo
if [ "$DRY_RUN" = "1" ]; then
echo " [dry-run] would cook: ${closure[*]}"
else
# The rebuild may legitimately fail if upstream deps aren't
# all built (a fresh checkout has no cooked sysroot). The
# user's intent is "rebuild from scratch", not "ensure
# every dep is present". Report the failure but don't
# exit 1 — let the user inspect the log and re-run after
# addressing the missing dep.
if ./target/release/repo cook --jobs="$JOBS" "${closure[@]}" 2>&1 | tee "$LOG_DIR/rebuild.log"; then
rebuild_status="success"
else
rebuild_status="FAILED (see log)"
fi
fi
echo
echo "=== Scratch rebuild complete (status: ${rebuild_status:-skipped/dry-run}) ==="
echo "Log: $LOG_DIR/rebuild.log"