From 6cde90042e70175bcd93c15eb7038e7fc9fe5dc8 Mon Sep 17 00:00:00 2001 From: tracer Date: Wed, 18 Feb 2026 22:49:28 +0100 Subject: [PATCH] harden update script and add bats CI coverage --- .gitea/workflows/commit.yaml | 12 +- CHANGELOG.md | 4 + git_update.sh | 255 +++++++++++++++++++++++++---------- tests/shell/git_update.bats | 52 +++++++ 4 files changed, 247 insertions(+), 76 deletions(-) create mode 100644 tests/shell/git_update.bats diff --git a/.gitea/workflows/commit.yaml b/.gitea/workflows/commit.yaml index c26ade6..e2e3dc3 100644 --- a/.gitea/workflows/commit.yaml +++ b/.gitea/workflows/commit.yaml @@ -8,10 +8,14 @@ jobs: test: runs-on: debian-latest steps: - - name: Show Debian version - run: cat /etc/os-release - - name: Test Deployment - run: echo "Deployment test" + - name: Checkout + uses: actions/checkout@v4 + - name: Install Bats + run: | + sudo apt-get update + sudo apt-get install -y bats + - name: Run Shell Tests + run: bats tests/shell/git_update.bats deploy: runs-on: self-hosted diff --git a/CHANGELOG.md b/CHANGELOG.md index 0981817..68261a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ - Updated ACP System -> CLI to show the CLI default PHP path/version in the panel header with sufficiency indicator and warning tooltip. - Simplified ACP CLI PHP selector to `php` or custom binary, and blocked saving `keyhelp-php-domain` from ACP. - Added test coverage expectation for `php_default_version` in system status unit tests. +- Hardened `git_update.sh` PHP selection flow with clearer logging (`initial fallback`, `bootstrap read`, `final binary`). +- Added strict PHP requirement enforcement in `git_update.sh` against `composer.json` and abort on insufficient CLI PHP. +- Refactored `git_update.sh` to `main()` for source-safe testing and added Bats shell tests for resolver/requirement behavior. +- Updated Gitea CI test job to install Bats and run `tests/shell/git_update.bats`. ## 2026-02-12 - Refined ACP System tab with left navigation, section-specific requirements, and CLI PHP selector. diff --git a/git_update.sh b/git_update.sh index 456a025..d8eef96 100755 --- a/git_update.sh +++ b/git_update.sh @@ -3,28 +3,6 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -cd "$ROOT_DIR" - -git restore -q bootstrap/cache/packages.php bootstrap/cache/services.php 2>/dev/null || true -DIRTY="$(git status --porcelain)" -DIRTY_FILTERED="$(echo "$DIRTY" | grep -vE '^( M|M ) (bootstrap/cache/(packages|services)\.php|package-lock\.json)$' || true)" -if [[ -n "$DIRTY_FILTERED" ]]; then - echo "Working tree is dirty. Please commit or stash changes before updating." - echo "$DIRTY_FILTERED" - exit 1 -fi -if echo "$DIRTY" | grep -qE 'package-lock\.json'; then - echo "Warning: package-lock.json is modified. Continuing anyway." -fi - -echo "Fetching latest refs..." -git fetch --prune --tags - -echo "Checking out stable branch..." -git checkout stable - -echo "Pulling latest stable..." -git pull --ff-only resolve_php_bin() { if [[ -n "${PHP_BIN:-}" ]]; then @@ -92,7 +70,7 @@ read_setting_php_bin() { echo "" return 0 fi - echo "Running with PHP binary: $PHP_BIN -r " >&2 + echo "Using bootstrap PHP binary to read system.php_binary: $PHP_BIN" >&2 "$PHP_BIN" -r ' require "vendor/autoload.php"; $app = require "bootstrap/app.php"; @@ -102,57 +80,185 @@ echo trim($value); ' } -PHP_BIN="$(resolve_php_bin)" -echo "Resolved PHP binary: $PHP_BIN" -if command -v "$PHP_BIN" >/dev/null 2>&1; then - echo "PHP version ($PHP_BIN): $($PHP_BIN -v | head -n 1)" -else - echo "PHP binary '$PHP_BIN' not found in PATH." -fi +enforce_php_requirement() { + local bin="${1:-php}" + echo "Validating PHP requirement from composer.json with binary: $bin" + "$bin" -r ' +$composer = json_decode((string) file_get_contents("composer.json"), true); +$constraint = (string) ($composer["require"]["php"] ?? ""); +$current = PHP_VERSION; -echo "Installing PHP dependencies..." -COMPOSER_BIN="$(command -v composer || true)" -if [[ -z "$COMPOSER_BIN" ]]; then - echo "Composer not found in PATH." - exit 1 -fi -echo "Running with PHP binary: $PHP_BIN $COMPOSER_BIN install --no-dev --optimize-autoloader" -"$PHP_BIN" "$COMPOSER_BIN" install --no-dev --optimize-autoloader +if ($constraint === "") { + fwrite(STDOUT, "No PHP requirement found in composer.json; skipping check.\n"); + exit(0); +} -if ! CONFIGURED_PHP="$(read_setting_php_bin)"; then - echo "Failed to read configured PHP binary from settings." >&2 - echo "Aborting to avoid running update with the wrong PHP binary." >&2 - exit 1 -fi -echo "Configured PHP binary from settings: ${CONFIGURED_PHP:-}" -PHP_BIN="$(resolve_configured_php_bin "$CONFIGURED_PHP" "$PHP_BIN")" +$normalize = static function (string $value): ?array { + if (!preg_match("/(\\d+)(?:\\.(\\d+))?(?:\\.(\\d+))?/", $value, $m)) { + return null; + } + return [(int) $m[1], (int) ($m[2] ?? 0), (int) ($m[3] ?? 0)]; +}; -echo "Final PHP binary: $PHP_BIN" -if command -v "$PHP_BIN" >/dev/null 2>&1; then - echo "Final PHP version ($PHP_BIN): $($PHP_BIN -v | head -n 1)" -fi +$cmp = static function (array $a, array $b): int { + for ($i = 0; $i < 3; $i++) { + if ($a[$i] > $b[$i]) { + return 1; + } + if ($a[$i] < $b[$i]) { + return -1; + } + } + return 0; +}; -echo "Installing JS dependencies..." -npm install +$partMin = static function (string $part) use ($normalize): ?array { + $tokens = preg_split("/\\s+/", trim($part)) ?: []; + $tokens = array_values(array_filter($tokens, static fn ($t) => $t !== "")); -echo "Building assets..." -npm run build + foreach ($tokens as $token) { + if (str_starts_with($token, ">=")) { + $parsed = $normalize(substr($token, 2)); + if ($parsed) { + return $parsed; + } + } + } -echo "Running migrations..." -echo "Running with PHP binary: $PHP_BIN artisan migrate --force" -"$PHP_BIN" artisan migrate --force + foreach ($tokens as $token) { + if (str_starts_with($token, "^")) { + $parsed = $normalize(substr($token, 1)); + if ($parsed) { + return $parsed; + } + } + if (str_starts_with($token, "~")) { + $parsed = $normalize(substr($token, 1)); + if ($parsed) { + return $parsed; + } + } + } -echo "Syncing version/build to settings..." -echo "Running with PHP binary: $PHP_BIN -r " -VERSION="$("$PHP_BIN" -r '$c=json_decode(file_get_contents("composer.json"), true); echo $c["version"] ?? "";')" -echo "Running with PHP binary: $PHP_BIN -r " -BUILD="$("$PHP_BIN" -r '$c=json_decode(file_get_contents("composer.json"), true); echo $c["build"] ?? "";')" -echo "Computed from composer.json: VERSION=$VERSION, BUILD=$BUILD" + return isset($tokens[0]) ? $normalize($tokens[0]) : null; +}; -if [[ -n "$VERSION" || -n "$BUILD" ]]; then - echo "Updating settings version/build (VERSION=$VERSION, BUILD=$BUILD)..." - echo "Running with PHP binary: $PHP_BIN -r " - SPEEDBB_VERSION="$VERSION" SPEEDBB_BUILD="$BUILD" "$PHP_BIN" -r ' +$parts = array_values(array_filter(array_map("trim", explode("||", $constraint)), static fn ($p) => $p !== "")); +$mins = []; +foreach ($parts as $part) { + $min = $partMin($part); + if ($min) { + $mins[] = $min; + } +} + +if (!$mins) { + fwrite(STDOUT, "Could not parse PHP requirement \"$constraint\"; skipping strict check.\n"); + exit(0); +} + +$required = array_reduce($mins, static function ($carry, $item) use ($cmp) { + if ($carry === null) { + return $item; + } + return $cmp($item, $carry) < 0 ? $item : $carry; +}); + +$currentParts = $normalize($current); +if (!$currentParts) { + fwrite(STDERR, "Unable to parse current PHP version: $current\n"); + exit(1); +} + +$requiredString = implode(".", $required); +if ($cmp($currentParts, $required) < 0) { + fwrite(STDERR, "PHP requirement check failed: composer.json requires \"$constraint\" (>= $requiredString), current is $current.\n"); + exit(1); +} + +fwrite(STDOUT, "PHP requirement check passed: composer.json requires \"$constraint\" (>= $requiredString), current is $current.\n"); +' || return 1 +} + +main() { + cd "$ROOT_DIR" + + git restore -q bootstrap/cache/packages.php bootstrap/cache/services.php 2>/dev/null || true + DIRTY="$(git status --porcelain)" + DIRTY_FILTERED="$(echo "$DIRTY" | grep -vE '^( M|M ) (bootstrap/cache/(packages|services)\.php|package-lock\.json)$' || true)" + if [[ -n "$DIRTY_FILTERED" ]]; then + echo "Working tree is dirty. Please commit or stash changes before updating." + echo "$DIRTY_FILTERED" + exit 1 + fi + if echo "$DIRTY" | grep -qE 'package-lock\.json'; then + echo "Warning: package-lock.json is modified. Continuing anyway." + fi + + echo "Fetching latest refs..." + git fetch --prune --tags + + echo "Checking out stable branch..." + git checkout stable + + echo "Pulling latest stable..." + git pull --ff-only + + PHP_BIN="$(resolve_php_bin)" + echo "Initial fallback PHP binary: $PHP_BIN" + if command -v "$PHP_BIN" >/dev/null 2>&1; then + echo "PHP version ($PHP_BIN): $($PHP_BIN -v | head -n 1)" + else + echo "PHP binary '$PHP_BIN' not found in PATH." + fi + + echo "Installing PHP dependencies..." + COMPOSER_BIN="$(command -v composer || true)" + if [[ -z "$COMPOSER_BIN" ]]; then + echo "Composer not found in PATH." + exit 1 + fi + echo "Running with PHP binary: $PHP_BIN $COMPOSER_BIN install --no-dev --optimize-autoloader" + "$PHP_BIN" "$COMPOSER_BIN" install --no-dev --optimize-autoloader + + if ! CONFIGURED_PHP="$(read_setting_php_bin)"; then + echo "Failed to read configured PHP binary from settings." >&2 + echo "Aborting to avoid running update with the wrong PHP binary." >&2 + exit 1 + fi + echo "Configured PHP binary from settings: ${CONFIGURED_PHP:-}" + PHP_BIN="$(resolve_configured_php_bin "$CONFIGURED_PHP" "$PHP_BIN")" + + echo "Final PHP binary: $PHP_BIN" + if command -v "$PHP_BIN" >/dev/null 2>&1; then + echo "Final PHP version ($PHP_BIN): $($PHP_BIN -v | head -n 1)" + fi + if ! enforce_php_requirement "$PHP_BIN"; then + echo "Aborting update because selected PHP binary does not satisfy composer.json requirements." >&2 + exit 1 + fi + + echo "Installing JS dependencies..." + npm install + + echo "Building assets..." + npm run build + + echo "Running migrations..." + echo "Running with PHP binary: $PHP_BIN artisan migrate --force" + "$PHP_BIN" artisan migrate --force + + echo "Syncing version/build to settings..." + echo "Running with PHP binary: $PHP_BIN -r " + VERSION="$("$PHP_BIN" -r '$c=json_decode(file_get_contents("composer.json"), true); echo $c["version"] ?? "";')" + echo "Running with PHP binary: $PHP_BIN -r " + BUILD="$("$PHP_BIN" -r '$c=json_decode(file_get_contents("composer.json"), true); echo $c["build"] ?? "";')" + echo "Computed from composer.json: VERSION=$VERSION, BUILD=$BUILD" + + if [[ -n "$VERSION" || -n "$BUILD" ]]; then + echo "Updating settings version/build (VERSION=$VERSION, BUILD=$BUILD)..." + echo "Running with PHP binary: $PHP_BIN -r " + SPEEDBB_VERSION="$VERSION" SPEEDBB_BUILD="$BUILD" "$PHP_BIN" -r ' require "vendor/autoload.php"; $app = require "bootstrap/app.php"; $app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap(); @@ -184,16 +290,21 @@ if [[ -n "$VERSION" || -n "$BUILD" ]]; then ); echo "Upserted build setting.\n"; } - ' \ - && echo "Running with PHP binary: $PHP_BIN -r " \ - && "$PHP_BIN" -r ' + ' \ + && echo "Running with PHP binary: $PHP_BIN -r " \ + && "$PHP_BIN" -r ' require "vendor/autoload.php"; $app = require "bootstrap/app.php"; $app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap(); $version = \App\Models\Setting::where("key", "version")->value("value"); $build = \App\Models\Setting::where("key", "build")->value("value"); echo "Settings now: version={$version}, build={$build}\n"; - ' -fi + ' + fi -echo "Update complete." + echo "Update complete." +} + +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + main "$@" +fi diff --git a/tests/shell/git_update.bats b/tests/shell/git_update.bats new file mode 100644 index 0000000..0e9e34f --- /dev/null +++ b/tests/shell/git_update.bats @@ -0,0 +1,52 @@ +#!/usr/bin/env bats + +setup() { + REPO_ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)" + SCRIPT_PATH="$REPO_ROOT/git_update.sh" + TMP_ROOT="$BATS_TEST_TMPDIR/work" + mkdir -p "$TMP_ROOT/bin" +} + +@test "git_update.sh can be sourced without running main" { + run bash -lc "source '$SCRIPT_PATH' >/dev/null 2>&1; echo sourced" + [ "$status" -eq 0 ] + [[ "$output" == "sourced" ]] +} + +@test "resolve_configured_php_bin accepts command name from PATH" { + cat >"$TMP_ROOT/bin/php84" <<'SH' +#!/usr/bin/env sh +exit 0 +SH + chmod +x "$TMP_ROOT/bin/php84" + + run bash -lc "PATH='$TMP_ROOT/bin':\$PATH; source '$SCRIPT_PATH'; resolve_configured_php_bin 'php84' 'php'" + [ "$status" -eq 0 ] + [ "$output" = "php84" ] +} + +@test "resolve_configured_php_bin rejects unknown custom command" { + run bash -lc "PATH='$TMP_ROOT/bin'; source '$SCRIPT_PATH'; resolve_configured_php_bin 'does-not-exist' 'php'" + [ "$status" -eq 1 ] + [[ "$output" == *"Configured PHP binary 'does-not-exist' is not executable/resolvable."* ]] +} + +@test "enforce_php_requirement passes for satisfied constraint" { + cat >"$TMP_ROOT/composer.json" <<'JSON' +{"require":{"php":">=8.0"}} +JSON + + run bash -lc "cd '$TMP_ROOT'; source '$SCRIPT_PATH'; enforce_php_requirement php" + [ "$status" -eq 0 ] + [[ "$output" == *"PHP requirement check passed"* ]] +} + +@test "enforce_php_requirement fails for unsatisfied constraint" { + cat >"$TMP_ROOT/composer.json" <<'JSON' +{"require":{"php":">=99.0"}} +JSON + + run bash -lc "cd '$TMP_ROOT'; source '$SCRIPT_PATH'; enforce_php_requirement php" + [ "$status" -eq 1 ] + [[ "$output" == *"PHP requirement check failed"* ]] +}