cb424d7448
verify-patch-sanity.py validates every active recipe .patch has internally- consistent hunk line counts — catching the 'malformed patch at line N' failure at commit/CI/preflight time instead of hours into a cook. This cycle hit that class three times (qtwaylandscanner, sddm, xwayland), each only discovered when cookbook tried to apply the patch. Running it across the repo found 29 latent malformed patches (validated against GNU patch: e.g. relibc/P3-sysv-ipc reproduces 'malformed patch at line 22'). They were harmless only because they sit in vendored recipes (baked, not re- applied) — but would fail on any version-bump re-derivation. --fix recounts the hunk headers (body untouched) and repaired all 29. Wired into build-preflight.sh (Phase 1.0D) and redbear-ci.yml, with a unit test (test-patch-sanity.sh). Skips archived/legacy trees and unvalidatable formats (empty placeholders, bare-@@ git hunks).
311 lines
14 KiB
Bash
Executable File
311 lines
14 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
|
|
|
CONFIG="redbear-full"
|
|
RELEASE="${REDBEAR_RELEASE:-}"
|
|
STRICT_DURABILITY="${REDBEAR_STRICT_DURABILITY:-0}"
|
|
STRICT_METADATA="${REDBEAR_STRICT_METADATA:-0}"
|
|
EXTRA_PACKAGES=()
|
|
|
|
usage() {
|
|
cat <<EOF
|
|
Usage: $(basename "$0") [--config=<name>] [--release=<ver>] [--strict-durability] [--strict-metadata] [--extra-package=<pkg> ...]
|
|
EOF
|
|
}
|
|
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--config=*) CONFIG="${1#*=}" ;;
|
|
--release=*) RELEASE="${1#*=}" ;;
|
|
--strict-durability) STRICT_DURABILITY=1 ;;
|
|
--strict-metadata) STRICT_METADATA=1 ;;
|
|
--extra-package=*) EXTRA_PACKAGES+=("${1#*=}") ;;
|
|
-h|--help) usage; exit 0 ;;
|
|
*) echo "Unknown: $1" >&2; usage >&2; exit 1 ;;
|
|
esac
|
|
shift
|
|
done
|
|
|
|
cd "$PROJECT_ROOT"
|
|
|
|
echo ">>> Build preflight: $CONFIG"
|
|
|
|
# Defensive: detect and repair broken recipe.toml symlinks.
|
|
# These are tracked files — broken symlinks mean corruption from unknown source.
|
|
# Check both local/recipes/ (regular files) and recipes/ (tracked symlinks).
|
|
broken_recipes=$(find local/recipes recipes -name "recipe.toml" -xtype l 2>/dev/null | wc -l)
|
|
if [ "$broken_recipes" -gt 0 ]; then
|
|
echo ">>> WARNING: $broken_recipes broken recipe.toml symlinks detected — auto-repairing from git..." >&2
|
|
find local/recipes recipes -name "recipe.toml" -xtype l -exec git checkout -q -- {} \; 2>/dev/null
|
|
remaining=$(find local/recipes recipes -name "recipe.toml" -xtype l 2>/dev/null | wc -l)
|
|
if [ "$remaining" -gt 0 ] && [ -x "$SCRIPT_DIR/guard-recipes.sh" ]; then
|
|
# `git checkout` only restores TRACKED links. Untracked/generated links
|
|
# (custom local/recipes symlinks that were never committed) must be
|
|
# regenerated by the durability guard, which recomputes correct relative
|
|
# targets from local/recipes/.
|
|
echo ">>> $remaining link(s) not restorable from git — regenerating via guard-recipes.sh --fix..." >&2
|
|
"$SCRIPT_DIR/guard-recipes.sh" --fix >/dev/null 2>&1 || true
|
|
remaining=$(find local/recipes recipes -name "recipe.toml" -xtype l 2>/dev/null | wc -l)
|
|
fi
|
|
if [ "$remaining" -gt 0 ]; then
|
|
echo ">>> ERROR: $remaining recipe.toml files could not be restored. Build may fail." >&2
|
|
else
|
|
echo ">>> Repaired: all recipe.toml symlinks restored."
|
|
fi
|
|
fi
|
|
|
|
if [ -x "$SCRIPT_DIR/verify-overlay-integrity.sh" ]; then
|
|
if ! "$SCRIPT_DIR/verify-overlay-integrity.sh" --quiet; then
|
|
echo ">>> Preflight note: overlay integrity script reported legacy issues; continuing with build-focused checks."
|
|
fi
|
|
fi
|
|
|
|
# Enforce the "no fake version label" rule (local/AGENTS.md):
|
|
# every Cat 2 fork's `<X.Y.Z>-rb<B.B.B>` version must match the source
|
|
# content from upstream `<X.Y.Z>` + Red Bear patches, and the `-rb<B.B.B>`
|
|
# suffix must match the current Red Bear OS git branch.
|
|
if [ -x "$SCRIPT_DIR/verify-fork-versions.sh" ]; then
|
|
if ! "$SCRIPT_DIR/verify-fork-versions.sh" >/tmp/fork-verify.out 2>&1; then
|
|
if [ "${REDBEAR_SKIP_FORK_VERIFY:-0}" != "1" ]; then
|
|
cat /tmp/fork-verify.out >&2
|
|
echo "ERROR: fork version verification failed." >&2
|
|
echo " Set REDBEAR_SKIP_FORK_VERIFY=1 to bypass (DANGEROUS, only for emergency)." >&2
|
|
exit 1
|
|
fi
|
|
echo ">>> Preflight note: fork version verification bypassed via REDBEAR_SKIP_FORK_VERIFY=1." >&2
|
|
else
|
|
echo ">>> Preflight: fork versions verified against upstream."
|
|
fi
|
|
fi
|
|
|
|
if [ -x "$SCRIPT_DIR/check-fork-drift.sh" ] && [ "${REDBEAR_SKIP_DRIFT_CHECK:-0}" != "1" ]; then
|
|
if ! "$SCRIPT_DIR/check-fork-drift.sh" --no-fetch --threshold=50 >/tmp/fork-drift.out 2>&1; then
|
|
echo ">>> WARNING: Upstream drift detected in local forks:" >&2
|
|
grep -E 'DRIFT' /tmp/fork-drift.out >&2 || cat /tmp/fork-drift.out >&2
|
|
echo ">>> Run ./local/scripts/check-fork-drift.sh for full report." >&2
|
|
echo ">>> Run ./local/scripts/upgrade-forks.sh to upgrade." >&2
|
|
echo ">>> Set REDBEAR_SKIP_DRIFT_CHECK=1 to suppress this warning." >&2
|
|
else
|
|
echo ">>> Preflight: fork drift check passed."
|
|
fi
|
|
fi
|
|
|
|
if [ -x "$SCRIPT_DIR/verify-fork-functions.sh" ] && [ "${REDBEAR_SKIP_FUNCTION_CHECK:-0}" != "1" ]; then
|
|
if ! "$SCRIPT_DIR/verify-fork-functions.sh" --no-fetch --quiet >/tmp/fork-functions.out 2>&1; then
|
|
cat /tmp/fork-functions.out >&2
|
|
echo ">>> ERROR: Fork function verification failed — upstream functions are missing." >&2
|
|
echo ">>> This indicates a bad merge/cherry-pick dropped upstream code silently." >&2
|
|
echo ">>> Fix: ./local/scripts/upgrade-forks.sh --no-fetch --force-reapply <fork>" >&2
|
|
if [ "${REDBEAR_SKIP_FORK_VERIFY:-0}" != "1" ]; then
|
|
exit 1
|
|
fi
|
|
echo ">>> WARNING: Bypassed via REDBEAR_SKIP_FORK_VERIFY=1." >&2
|
|
else
|
|
echo ">>> Preflight: fork function verification passed."
|
|
fi
|
|
fi
|
|
|
|
# Phase 1.0C: external tar-recipe version consistency.
|
|
# The local/sources/ forks are guarded above; this guards the OTHER fork family:
|
|
# tar recipes that vendor their upstream source/ in git while also declaring a
|
|
# [source] tar=/blake3. A version bump that rewrites recipe metadata but never
|
|
# propagates into the vendored source/ (or a stale cached source.tar) makes the
|
|
# build silently compile the wrong version (the Qt 6.11.0/6.11.1 and KDE
|
|
# 6.10.0/6.28.0 divergences). Fix: ./local/scripts/sync-recipe-source.sh <recipe>
|
|
if [ -x "$SCRIPT_DIR/verify-external-source-versions.sh" ] && [ "${REDBEAR_SKIP_EXTERNAL_SOURCE_CHECK:-0}" != "1" ]; then
|
|
if ! "$SCRIPT_DIR/verify-external-source-versions.sh" --quiet >/tmp/ext-source.out 2>&1; then
|
|
cat /tmp/ext-source.out >&2
|
|
echo ">>> ERROR: external tar-recipe source version divergence (recipe vs vendored source/)." >&2
|
|
echo ">>> Fix: ./local/scripts/sync-recipe-source.sh --commit <recipe> (propagates the bump)" >&2
|
|
if [ "${REDBEAR_SKIP_FORK_VERIFY:-0}" != "1" ]; then
|
|
exit 1
|
|
fi
|
|
echo ">>> WARNING: Bypassed via REDBEAR_SKIP_FORK_VERIFY=1." >&2
|
|
else
|
|
echo ">>> Preflight: external tar-recipe source versions consistent."
|
|
fi
|
|
fi
|
|
|
|
# Phase 1.0D: static patch-sanity. Validates that every active recipe .patch has
|
|
# internally-consistent hunk line counts, catching the "malformed patch at line N"
|
|
# class at preflight instead of hours into a cook (hit 3x this cycle:
|
|
# qtwaylandscanner/sddm/xwayland). Fix: ./local/scripts/verify-patch-sanity.py --fix
|
|
if [ -x "$SCRIPT_DIR/verify-patch-sanity.py" ] || [ -f "$SCRIPT_DIR/verify-patch-sanity.py" ]; then
|
|
if ! python3 "$SCRIPT_DIR/verify-patch-sanity.py" --quiet >/tmp/patch-sanity.out 2>&1; then
|
|
cat /tmp/patch-sanity.out >&2
|
|
echo ">>> ERROR: malformed recipe patch(es). Fix: ./local/scripts/verify-patch-sanity.py --fix" >&2
|
|
if [ "${REDBEAR_SKIP_FORK_VERIFY:-0}" != "1" ]; then exit 1; fi
|
|
echo ">>> WARNING: Bypassed via REDBEAR_SKIP_FORK_VERIFY=1." >&2
|
|
else
|
|
echo ">>> Preflight: recipe patches are well-formed."
|
|
fi
|
|
fi
|
|
|
|
# Phase 1.0B: enforce patch-presence invariant. Every patch in
|
|
# local/patches/<comp>/ MUST have its content reflected as commits in
|
|
# local/sources/<comp>/'s working tree, or the next fork reset will
|
|
# silently lose that work. See local/docs/legacy-obsolete-2026-07-25/PATCH-PRESERVATION-AUDIT-2026-07-12.md.
|
|
# Default is warn-only; --strict was decided against because current state
|
|
# is mid-recovery and we don't want to block every operator build.
|
|
if [ -x "$SCRIPT_DIR/verify-patch-content.sh" ] && [ "${REDBEAR_SKIP_PATCH_CONTENT_CHECK:-0}" != "1" ]; then
|
|
if ! "$SCRIPT_DIR/verify-patch-content.sh" >/tmp/patch-content.out 2>&1; then
|
|
cat /tmp/patch-content.out >&2
|
|
echo ">>> WARNING: Orphaned patches detected (Phase 1.0B). Build proceeding but Red Bear" >&2
|
|
echo ">>> work in orphaned patches is NOT in the fork. See audit doc for recovery." >&2
|
|
echo ">>> Set REDBEAR_SKIP_PATCH_CONTENT_CHECK=1 to suppress this warning." >&2
|
|
echo ">>> Run ./local/scripts/verify-patch-content.sh --strict for exit-1 enforcement." >&2
|
|
fi
|
|
fi
|
|
|
|
# Phase 4.2: collision detection — config [[files]] vs recipe installs.
|
|
# The original AGENTS.md "Collision Implications" claim that runtime
|
|
# collision detection exists was Phase 0 audit-disproven. This implements
|
|
# the GENERAL variant (config-vs-package) at pre-flight time so the
|
|
# silent-collision class of bug surfaces before install_packages().
|
|
if [ -x "$SCRIPT_DIR/verify-collision-detection.py" ] && [ "${REDBEAR_SKIP_COLLISION_CHECK:-0}" != "1" ]; then
|
|
if ! python3 "$SCRIPT_DIR/verify-collision-detection.py" >/tmp/collision.out 2>&1; then
|
|
cat /tmp/collision.out >&2
|
|
echo ">>> ERROR: Package-vs-config collision detected. Refusing to proceed." >&2
|
|
echo ">>> Set REDBEAR_SKIP_COLLISION_CHECK=1 to bypass (DANGEROUS)." >&2
|
|
else
|
|
echo ">>> Preflight: collision detection passed (config [[files]] vs recipe installs)."
|
|
fi
|
|
fi
|
|
|
|
if [ -n "$RELEASE" ]; then
|
|
bash "$SCRIPT_DIR/build-release-mode.sh" --release="$RELEASE" --config="$CONFIG" "${EXTRA_PACKAGES[@]/#/--extra-package=}"
|
|
fi
|
|
|
|
python3 "$SCRIPT_DIR/validate-source-trees.py" "$CONFIG" "${EXTRA_PACKAGES[@]/#/--extra-package=}"
|
|
|
|
python3 - "$PROJECT_ROOT" "$CONFIG" "$STRICT_METADATA" "${EXTRA_PACKAGES[@]}" <<'PY'
|
|
import sys
|
|
import tomllib
|
|
from pathlib import Path
|
|
|
|
project_root = Path(sys.argv[1])
|
|
config_name = sys.argv[2]
|
|
strict_metadata = sys.argv[3] == "1"
|
|
extra_packages = sys.argv[4:]
|
|
|
|
def build_lookup():
|
|
lookup = {}
|
|
for root in (project_root / "recipes", project_root / "local/recipes"):
|
|
for recipe_toml in root.rglob("recipe.toml"):
|
|
if not recipe_toml.exists():
|
|
continue
|
|
parts = recipe_toml.parts
|
|
if "source" in parts or "target" in parts:
|
|
continue
|
|
package_name = recipe_toml.parent.name
|
|
lookup.setdefault(package_name, recipe_toml)
|
|
return lookup
|
|
|
|
def resolve_config(config_path, visited=None):
|
|
if visited is None:
|
|
visited = set()
|
|
config_path = config_path.resolve()
|
|
if config_path in visited:
|
|
return {}
|
|
visited.add(config_path)
|
|
config = tomllib.loads(config_path.read_text())
|
|
packages = dict(config.get("packages", {}))
|
|
for include in config.get("include", []):
|
|
include_path = config_path.parent / include
|
|
if include_path.exists():
|
|
included = resolve_config(include_path, visited)
|
|
for name, value in packages.items():
|
|
included[name] = value
|
|
packages = included
|
|
return packages
|
|
|
|
lookup = build_lookup()
|
|
config_path = project_root / "config" / f"{config_name}.toml"
|
|
requested = resolve_config(config_path)
|
|
for pkg in extra_packages:
|
|
requested.setdefault(pkg, {})
|
|
|
|
known_package_paths = {
|
|
name for name in lookup
|
|
if not name.startswith("host:")
|
|
}
|
|
|
|
errors = []
|
|
warnings = []
|
|
for package_name, package_conf in sorted(requested.items()):
|
|
if str(package_conf) == "ignore" or package_name in {"libgcc", "libstdcxx"}:
|
|
continue
|
|
recipe_toml = lookup.get(package_name)
|
|
if recipe_toml is None:
|
|
continue
|
|
recipe = tomllib.loads(recipe_toml.read_text())
|
|
source = recipe.get("source", {})
|
|
rel = recipe_toml.relative_to(project_root).as_posix()
|
|
is_wip_or_local = rel.startswith("recipes/wip/") or rel.startswith("local/recipes/")
|
|
if isinstance(source, dict) and "tar" in source and "blake3" not in source:
|
|
msg = f"missing blake3 for tar recipe: {recipe_toml.relative_to(project_root)}"
|
|
(errors if strict_metadata and not is_wip_or_local else warnings).append(msg)
|
|
for patch in source.get("patches", []):
|
|
patch_path = (recipe_toml.parent / patch).resolve()
|
|
if not patch_path.exists():
|
|
msg = f"missing patch file: {patch} for {recipe_toml.relative_to(project_root)}"
|
|
(errors if strict_metadata else warnings).append(msg)
|
|
build = recipe.get("build", {})
|
|
script = build.get("script", "") if isinstance(build, dict) else ""
|
|
if script:
|
|
for dep in known_package_paths:
|
|
bad = f"/tmp/{dep}/"
|
|
if bad in script:
|
|
msg = (
|
|
f"recipe {recipe_toml.relative_to(project_root)} references {bad} "
|
|
f"in its build script. The cookbook does not stage host dev-dependencies "
|
|
f"under /tmp/<name>; use ${{COOKBOOK_TOOLCHAIN}} (the cross recipe's "
|
|
f"toolchain directory populated from dev-dependencies) instead."
|
|
)
|
|
(errors if strict_metadata else warnings).append(msg)
|
|
|
|
for warning in warnings:
|
|
print(f"WARN: {warning}", file=sys.stderr)
|
|
if errors:
|
|
for error in errors:
|
|
print(f"ERROR: {error}", file=sys.stderr)
|
|
raise SystemExit(1)
|
|
PY
|
|
|
|
if [ -x "$SCRIPT_DIR/classify-patch-state.py" ]; then
|
|
python3 "$SCRIPT_DIR/classify-patch-state.py"
|
|
fi
|
|
|
|
if [ -x "$SCRIPT_DIR/verify-durable-source-edits.py" ]; then
|
|
args=()
|
|
if [ "$STRICT_DURABILITY" = "1" ]; then
|
|
args+=(--strict)
|
|
fi
|
|
python3 "$SCRIPT_DIR/verify-durable-source-edits.py" "${args[@]}"
|
|
fi
|
|
|
|
if [ "$CONFIG" = "redbear-full" ]; then
|
|
relibc_include="$(PROJECT_ROOT="$PROJECT_ROOT" bash -c 'source "$0"; redbear_relibc_stage_include_dir' "$SCRIPT_DIR/lib/relibc-surface.sh")"
|
|
relibc_lib_dir="$(PROJECT_ROOT="$PROJECT_ROOT" bash -c 'source "$0"; redbear_relibc_stage_lib_dir' "$SCRIPT_DIR/lib/relibc-surface.sh")"
|
|
if [ -d "$relibc_include" ] && [ -f "$relibc_lib_dir/libc.so" ]; then
|
|
for hdr in sys/signalfd.h sys/timerfd.h sys/eventfd.h threads.h; do
|
|
if [ ! -f "$relibc_include/$hdr" ]; then
|
|
echo ">>> Preflight note: relibc staged surface missing $hdr; top-level build should refresh/sync it before compilation."
|
|
fi
|
|
done
|
|
if ! grep -q 'strtold' "$relibc_include/stdlib.h" 2>/dev/null; then
|
|
echo ">>> Preflight note: relibc staged stdlib.h missing strtold declaration; top-level build should refresh/sync it before compilation."
|
|
fi
|
|
if ! readelf -Ws "$relibc_lib_dir/libc.so" | grep -q '_Z7strtoldPKcPPc'; then
|
|
echo ">>> Preflight note: relibc staged libc.so missing C++ strtold compatibility export; top-level build should refresh/sync it before compilation."
|
|
fi
|
|
else
|
|
echo ">>> Preflight note: relibc staged surface not present yet; build will refresh it if needed."
|
|
fi
|
|
fi
|
|
|
|
echo ">>> Build preflight passed"
|