5 Commits

Author SHA1 Message Date
fdf8d65310 ci: trigger dev_tests
Some checks failed
CI/CD Pipeline / dev_tests (push) Failing after 2s
CI/CD Pipeline / deploy (push) Has been skipped
CI/CD Pipeline / promote_stable (push) Has been skipped
2026-02-19 18:17:37 +01:00
c2140b4493 shel test
Some checks failed
CI/CD Pipeline / dev_tests (push) Failing after 2s
CI/CD Pipeline / deploy (push) Has been skipped
CI/CD Pipeline / promote_stable (push) Has been skipped
2026-02-18 23:14:46 +01:00
652cf8bd6a fix dev CI checkout without node action
Some checks failed
CI/CD Pipeline / test (push) Failing after 3s
CI/CD Pipeline / deploy (push) Has been skipped
CI/CD Pipeline / promote_stable (push) Has been skipped
2026-02-18 23:08:01 +01:00
5fdc0d45e3 run bats on dev and enforce php requirement status in ACP
Some checks failed
CI/CD Pipeline / test (push) Failing after 4s
CI/CD Pipeline / deploy (push) Has been skipped
CI/CD Pipeline / promote_stable (push) Has been skipped
2026-02-18 22:57:19 +01:00
6cde90042e harden update script and add bats CI coverage
Some checks failed
CI/CD Pipeline / test (push) Failing after 13s
CI/CD Pipeline / deploy (push) Has been skipped
CI/CD Pipeline / promote_stable (push) Has been skipped
2026-02-18 22:49:28 +01:00
5 changed files with 272 additions and 86 deletions

View File

@@ -3,19 +3,28 @@ run-name: ${{ gitea.event.head_commit.message }}
on: on:
push: push:
branches: branches:
- dev
- master - master
jobs: jobs:
test: dev_tests:
if: gitea.ref_name == 'dev'
runs-on: debian-latest runs-on: debian-latest
steps: steps:
- name: Show Debian version - name: Checkout
run: cat /etc/os-release run: |
- name: Test Deployment git clone --quiet --depth=1 --branch=${{ gitea.ref_name }} ${{ gitea.server_url }}/${{ gitea.repository }} repo
run: echo "Deployment test" - name: Install Bats
run: |
sudo apt-get update
sudo apt-get install -y bats
- name: Run Shell Tests
run: |
cd repo
bats tests/shell/git_update.bats
deploy: deploy:
if: gitea.ref_name == 'master'
runs-on: self-hosted runs-on: self-hosted
needs: test
steps: steps:
- name: Custom Checkout - name: Custom Checkout
env: env:
@@ -39,6 +48,7 @@ jobs:
rm .vault_pass.txt rm .vault_pass.txt
promote_stable: promote_stable:
if: gitea.ref_name == 'master'
runs-on: self-hosted runs-on: self-hosted
needs: deploy needs: deploy
steps: steps:

View File

@@ -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. - 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. - 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. - 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 ## 2026-02-12
- Refined ACP System tab with left navigation, section-specific requirements, and CLI PHP selector. - Refined ACP System tab with left navigation, section-specific requirements, and CLI PHP selector.

View File

@@ -3,28 +3,6 @@
set -euo pipefail set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 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() { resolve_php_bin() {
if [[ -n "${PHP_BIN:-}" ]]; then if [[ -n "${PHP_BIN:-}" ]]; then
@@ -92,7 +70,7 @@ read_setting_php_bin() {
echo "" echo ""
return 0 return 0
fi fi
echo "Running with PHP binary: $PHP_BIN -r <read system.php_binary>" >&2 echo "Using bootstrap PHP binary to read system.php_binary: $PHP_BIN" >&2
"$PHP_BIN" -r ' "$PHP_BIN" -r '
require "vendor/autoload.php"; require "vendor/autoload.php";
$app = require "bootstrap/app.php"; $app = require "bootstrap/app.php";
@@ -102,8 +80,132 @@ echo trim($value);
' '
} }
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;
if ($constraint === "") {
fwrite(STDOUT, "No PHP requirement found in composer.json; skipping check.\n");
exit(0);
}
$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)];
};
$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;
};
$partMin = static function (string $part) use ($normalize): ?array {
$tokens = preg_split("/\\s+/", trim($part)) ?: [];
$tokens = array_values(array_filter($tokens, static fn ($t) => $t !== ""));
foreach ($tokens as $token) {
if (str_starts_with($token, ">=")) {
$parsed = $normalize(substr($token, 2));
if ($parsed) {
return $parsed;
}
}
}
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;
}
}
}
return isset($tokens[0]) ? $normalize($tokens[0]) : null;
};
$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)" PHP_BIN="$(resolve_php_bin)"
echo "Resolved PHP binary: $PHP_BIN" echo "Initial fallback PHP binary: $PHP_BIN"
if command -v "$PHP_BIN" >/dev/null 2>&1; then if command -v "$PHP_BIN" >/dev/null 2>&1; then
echo "PHP version ($PHP_BIN): $($PHP_BIN -v | head -n 1)" echo "PHP version ($PHP_BIN): $($PHP_BIN -v | head -n 1)"
else else
@@ -131,6 +233,10 @@ echo "Final PHP binary: $PHP_BIN"
if command -v "$PHP_BIN" >/dev/null 2>&1; then if command -v "$PHP_BIN" >/dev/null 2>&1; then
echo "Final PHP version ($PHP_BIN): $($PHP_BIN -v | head -n 1)" echo "Final PHP version ($PHP_BIN): $($PHP_BIN -v | head -n 1)"
fi 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..." echo "Installing JS dependencies..."
npm install npm install
@@ -197,3 +303,8 @@ if [[ -n "$VERSION" || -n "$BUILD" ]]; then
fi fi
echo "Update complete." echo "Update complete."
}
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
main "$@"
fi

View File

@@ -63,7 +63,6 @@ const StatusIcon = ({ status = 'bad', tooltip }) => {
} }
function Acp({ isAdmin }) { function Acp({ isAdmin }) {
const forcedMinPhpForTesting = '>=8.5'
const { t } = useTranslation() const { t } = useTranslation()
const { roles: authRoles } = useAuth() const { roles: authRoles } = useAuth()
const canManageFounder = authRoles.includes('ROLE_FOUNDER') const canManageFounder = authRoles.includes('ROLE_FOUNDER')
@@ -470,11 +469,21 @@ function Acp({ isAdmin }) {
} }
const cliDefaultPhpIsSufficient = useMemo(() => { const cliDefaultPhpIsSufficient = useMemo(() => {
const minimum = parseMinPhpConstraint(systemStatus?.min_versions?.php)
const current = normalizeSemver(systemStatus?.php_default_version) const current = normalizeSemver(systemStatus?.php_default_version)
const minimum = parseMinPhpConstraint(forcedMinPhpForTesting || systemStatus?.min_versions?.php) if (!minimum) return true
if (!current || !minimum) return false if (!current) return false
return compareSemver(current, minimum) >= 0 return compareSemver(current, minimum) >= 0
}, [systemStatus, forcedMinPhpForTesting]) }, [systemStatus])
const phpSelectedIsSufficient = useMemo(() => {
if (!systemStatus?.php_selected_ok) return false
const minimum = parseMinPhpConstraint(systemStatus?.min_versions?.php)
const current = normalizeSemver(systemStatus?.php_selected_version)
if (!minimum) return true
if (!current) return false
return compareSemver(current, minimum) >= 0
}, [systemStatus])
const systemChecks = useMemo(() => { const systemChecks = useMemo(() => {
if (!systemStatus) return [] if (!systemStatus) return []
@@ -483,9 +492,9 @@ function Acp({ isAdmin }) {
id: 'php', id: 'php',
label: 'PHP', label: 'PHP',
path: systemStatus.php_selected_path || '—', path: systemStatus.php_selected_path || '—',
min: forcedMinPhpForTesting || systemStatus.min_versions?.php || '—', min: systemStatus.min_versions?.php || '—',
current: systemStatus.php_selected_version || '—', current: systemStatus.php_selected_version || '—',
status: systemStatus.php_selected_ok ? 'ok' : 'bad', status: phpSelectedIsSufficient ? 'ok' : 'bad',
}, },
{ {
id: 'composer', id: 'composer',
@@ -563,7 +572,7 @@ function Acp({ isAdmin }) {
status: systemStatus.updates_writable ? 'ok' : 'bad', status: systemStatus.updates_writable ? 'ok' : 'bad',
}, },
] ]
}, [systemStatus, t]) }, [phpSelectedIsSufficient, systemStatus, t])
const visibleSystemChecks = useMemo(() => { const visibleSystemChecks = useMemo(() => {
const visibilityBySection = { const visibilityBySection = {
@@ -3906,7 +3915,7 @@ function Acp({ isAdmin }) {
)} )}
<Form.Text className="bb-muted"> <Form.Text className="bb-muted">
Minimum required PHP (from composer.json):{' '} Minimum required PHP (from composer.json):{' '}
{(forcedMinPhpForTesting || systemStatus?.min_versions?.php) || 'unknown'}. Use a custom binary {systemStatus?.min_versions?.php || 'unknown'}. Use a custom binary
on like php84. On KeyHelp setups use e.g. `keyhelp-php84`. on like php84. On KeyHelp setups use e.g. `keyhelp-php84`.
</Form.Text> </Form.Text>
</Form.Group> </Form.Group>

View File

@@ -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"* ]]
}