Compare commits
8 Commits
942ab7858b
...
1c2353cfe1
| Author | SHA1 | Date | |
|---|---|---|---|
| 1c2353cfe1 | |||
| 496b50ed12 | |||
| 50e3ff6ded | |||
| fdf8d65310 | |||
| c2140b4493 | |||
| 652cf8bd6a | |||
| 5fdc0d45e3 | |||
| 6cde90042e |
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
159
git_update.sh
159
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 <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,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)"
|
||||
echo "Resolved PHP binary: $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
|
||||
@@ -131,6 +233,10 @@ 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
|
||||
@@ -197,3 +303,8 @@ if [[ -n "$VERSION" || -n "$BUILD" ]]; then
|
||||
fi
|
||||
|
||||
echo "Update complete."
|
||||
}
|
||||
|
||||
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
|
||||
main "$@"
|
||||
fi
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
12
tests/run-shell-tests.sh
Normal 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
|
||||
52
tests/shell/git_update.bats
Normal file
52
tests/shell/git_update.bats
Normal 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"* ]]
|
||||
}
|
||||
Reference in New Issue
Block a user