8 Commits

Author SHA1 Message Date
1c2353cfe1 fix artisan migration
All checks were successful
CI/CD Pipeline / deploy (push) Successful in 23s
CI/CD Pipeline / promote_stable (push) Successful in 2s
2026-02-23 12:23:53 +01:00
496b50ed12 added cancel to login
Some checks failed
CI/CD Pipeline / deploy (push) Failing after 28s
CI/CD Pipeline / promote_stable (push) Has been skipped
2026-02-23 12:11:44 +01:00
50e3ff6ded remove CI bats job and keep shell tests local 2026-02-19 18:22:43 +01:00
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
10 changed files with 287 additions and 95 deletions

View File

@@ -3,19 +3,12 @@ run-name: ${{ gitea.event.head_commit.message }}
on:
push:
branches:
- dev
- master
jobs:
test:
runs-on: debian-latest
steps:
- name: Show Debian version
run: cat /etc/os-release
- name: Test Deployment
run: echo "Deployment test"
deploy:
if: gitea.ref_name == 'master'
runs-on: self-hosted
needs: test
steps:
- name: Custom Checkout
env:
@@ -39,6 +32,7 @@ jobs:
rm .vault_pass.txt
promote_stable:
if: gitea.ref_name == 'master'
runs-on: self-hosted
needs: deploy
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.
- 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.

View File

@@ -93,8 +93,8 @@
msg: "Database backed up to: {{ backup_result.stdout }}"
when: env_file.stat.exists
- name: Run database migrations safely
command: "keyhelp-php84 artisan migrate:safe --force"
- name: Run database migrations
command: "keyhelp-php84 artisan migrate --force"
args:
chdir: "{{ prod_base_dir }}"
register: migrate_result

View File

@@ -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 <read system.php_binary>" >&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,54 +80,182 @@ 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
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)"
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
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
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:-<empty>}"
PHP_BIN="$(resolve_configured_php_bin "$CONFIGURED_PHP" "$PHP_BIN")"
fi
echo "Configured PHP binary from settings: ${CONFIGURED_PHP:-<empty>}"
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 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
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 "Installing JS dependencies..."
npm install
echo "Building assets..."
npm run build
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 "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 <read composer.json version>"
VERSION="$("$PHP_BIN" -r '$c=json_decode(file_get_contents("composer.json"), true); echo $c["version"] ?? "";')"
echo "Running with PHP binary: $PHP_BIN -r <read composer.json build>"
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"
echo "Syncing version/build to settings..."
echo "Running with PHP binary: $PHP_BIN -r <read composer.json version>"
VERSION="$("$PHP_BIN" -r '$c=json_decode(file_get_contents("composer.json"), true); echo $c["version"] ?? "";')"
echo "Running with PHP binary: $PHP_BIN -r <read composer.json build>"
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
if [[ -n "$VERSION" || -n "$BUILD" ]]; then
echo "Updating settings version/build (VERSION=$VERSION, BUILD=$BUILD)..."
echo "Running with PHP binary: $PHP_BIN -r <write settings version/build>"
SPEEDBB_VERSION="$VERSION" SPEEDBB_BUILD="$BUILD" "$PHP_BIN" -r '
@@ -194,6 +300,11 @@ if [[ -n "$VERSION" || -n "$BUILD" ]]; then
$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

View File

@@ -63,7 +63,6 @@ const StatusIcon = ({ status = 'bad', tooltip }) => {
}
function Acp({ isAdmin }) {
const forcedMinPhpForTesting = '>=8.5'
const { t } = useTranslation()
const { roles: authRoles } = useAuth()
const canManageFounder = authRoles.includes('ROLE_FOUNDER')
@@ -470,11 +469,21 @@ function Acp({ isAdmin }) {
}
const cliDefaultPhpIsSufficient = useMemo(() => {
const minimum = parseMinPhpConstraint(systemStatus?.min_versions?.php)
const current = normalizeSemver(systemStatus?.php_default_version)
const minimum = parseMinPhpConstraint(forcedMinPhpForTesting || systemStatus?.min_versions?.php)
if (!current || !minimum) return false
if (!minimum) return true
if (!current) return false
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(() => {
if (!systemStatus) return []
@@ -483,9 +492,9 @@ function Acp({ isAdmin }) {
id: 'php',
label: 'PHP',
path: systemStatus.php_selected_path || '—',
min: forcedMinPhpForTesting || systemStatus.min_versions?.php || '—',
min: systemStatus.min_versions?.php || '—',
current: systemStatus.php_selected_version || '—',
status: systemStatus.php_selected_ok ? 'ok' : 'bad',
status: phpSelectedIsSufficient ? 'ok' : 'bad',
},
{
id: 'composer',
@@ -563,7 +572,7 @@ function Acp({ isAdmin }) {
status: systemStatus.updates_writable ? 'ok' : 'bad',
},
]
}, [systemStatus, t])
}, [phpSelectedIsSufficient, systemStatus, t])
const visibleSystemChecks = useMemo(() => {
const visibilityBySection = {
@@ -3906,7 +3915,7 @@ function Acp({ isAdmin }) {
)}
<Form.Text className="bb-muted">
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`.
</Form.Text>
</Form.Group>

View File

@@ -1,6 +1,6 @@
import { useState } from 'react'
import { Button, Card, Container, Form } from 'react-bootstrap'
import { useNavigate } from 'react-router-dom'
import { Link, useNavigate } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import { useTranslation } from 'react-i18next'
@@ -53,10 +53,18 @@ export default function Login() {
onChange={(event) => setPassword(event.target.value)}
required
/>
<div className="mt-2 text-end">
<Link to="/reset-password">{t('auth.forgot_password')}</Link>
</div>
</Form.Group>
<div className="d-flex gap-2">
<Button as={Link} to="/" type="button" variant="outline-secondary" disabled={loading}>
{t('acp.cancel')}
</Button>
<Button type="submit" variant="dark" disabled={loading}>
{loading ? t('form.signing_in') : t('form.sign_in')}
</Button>
</div>
</Form>
</Card.Body>
</Card>

View File

@@ -81,6 +81,7 @@
"auth.login_title": "Anmelden",
"auth.login_identifier": "E-Mail oder Benutzername",
"auth.login_placeholder": "name@example.com oder benutzername",
"auth.forgot_password": "Passwort vergessen?",
"auth.register_hint": "Registriere dich mit E-Mail und einem eindeutigen Benutzernamen.",
"auth.verify_notice": "Bitte bestätige deine E-Mail-Adresse, bevor du dich anmeldest.",
"auth.register_title": "Konto erstellen",

View File

@@ -81,6 +81,7 @@
"auth.login_title": "Log in",
"auth.login_identifier": "Email or username",
"auth.login_placeholder": "name@example.com or username",
"auth.forgot_password": "Forgot password?",
"auth.register_hint": "Register with an email and a unique username.",
"auth.verify_notice": "Check your email to verify your account before logging in.",
"auth.register_title": "Create account",

12
tests/run-shell-tests.sh Normal file
View File

@@ -0,0 +1,12 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"
if ! command -v bats >/dev/null 2>&1; then
echo "bats is not installed. Install with: brew install bats-core" >&2
exit 1
fi
bats tests/shell/git_update.bats

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