8 Commits

Author SHA1 Message Date
af03c23c9f Fix stable promotion fetch/merge
All checks were successful
CI/CD Pipeline / test (push) Successful in 2s
CI/CD Pipeline / deploy (push) Successful in 19s
CI/CD Pipeline / promote_stable (push) Successful in 3s
2026-02-11 17:46:56 +01:00
68dd17f895 Promote stable on successful deploy
Some checks failed
CI/CD Pipeline / test (push) Successful in 3s
CI/CD Pipeline / deploy (push) Successful in 23s
CI/CD Pipeline / promote_stable (push) Failing after 1s
2026-02-11 17:42:31 +01:00
8249df15df update stable if push to master is successful
Some checks failed
CI/CD Pipeline / test (push) Successful in 3s
CI/CD Pipeline / deploy (push) Successful in 18s
CI/CD Pipeline / promote_stable (push) Failing after 3s
2026-02-10 20:20:20 +01:00
f167e64d00 make the cli update executable
All checks were successful
CI/CD Pipeline / test (push) Successful in 2s
CI/CD Pipeline / deploy (push) Successful in 18s
2026-02-10 19:58:09 +01:00
95ebc7778d Update ACP system navigation
All checks were successful
CI/CD Pipeline / test (push) Successful in 2s
CI/CD Pipeline / deploy (push) Successful in 19s
2026-02-10 19:52:24 +01:00
c67a3ec6d0 Add pre-commit hook to verify DB
All checks were successful
CI/CD Pipeline / test (push) Successful in 3s
CI/CD Pipeline / deploy (push) Successful in 19s
2026-02-10 18:46:43 +01:00
bf278667bc Add git_update.sh and adjust update/test hooks
All checks were successful
CI/CD Pipeline / test (push) Successful in 3s
CI/CD Pipeline / deploy (push) Successful in 23s
2026-02-10 18:38:51 +01:00
30a06e18f0 Bump version
All checks were successful
CI/CD Pipeline / test (push) Successful in 2s
CI/CD Pipeline / deploy (push) Successful in 19s
2026-02-08 19:16:21 +01:00
11 changed files with 555 additions and 420 deletions

View File

@@ -37,3 +37,24 @@ jobs:
echo "$ANSIBLE_VAULT_PASSWORD" > .vault_pass.txt echo "$ANSIBLE_VAULT_PASSWORD" > .vault_pass.txt
ansible-playbook --vault-password-file .vault_pass.txt deploy-to-prod.yaml ansible-playbook --vault-password-file .vault_pass.txt deploy-to-prod.yaml
rm .vault_pass.txt rm .vault_pass.txt
promote_stable:
runs-on: self-hosted
needs: deploy
steps:
- name: Promote master to stable
env:
SPEEDBB_REPO: ${{ vars.SPEEDBB_REPO }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
GITEA_ACTOR: ${{ gitea.actor }}
run: |
set -e
REPO="$SPEEDBB_REPO"
if [ -n "$GITEA_TOKEN" ]; then
REPO=$(echo "$SPEEDBB_REPO" | sed "s#https://#https://${GITEA_ACTOR}:${GITEA_TOKEN}@#")
fi
git clone --quiet --depth=1 --branch=stable "$REPO" repo
cd repo
git fetch origin master
git merge --ff-only FETCH_HEAD
git push origin stable

View File

@@ -1,9 +1,14 @@
# Changelog # Changelog
## 2026-02-10
- Reshaped ACP System tab with left navigation and dedicated views (Overview, Live Update, CLI, CI/CD).
- Moved system requirements table into the CI/CD view with refresh controls.
## 2026-02-08 ## 2026-02-08
- Achieved 100% test coverage across the backend. - Achieved 100% test coverage across the backend.
- Added comprehensive Feature and Unit tests for controllers, models, services, and console commands. - Added comprehensive Feature and Unit tests for controllers, models, services, and console commands.
- Added extensive edge-case and error-path coverage (system update/status, versioning, attachments, forums, roles, ranks, settings, portal, etc.). - Added extensive edge-case and error-path coverage (system update/status, versioning, attachments, forums, roles, ranks, settings, portal, etc.).
- Added `git_update.sh` for CLI-based updates (stable branch, deps, build, migrations, version sync).
## 2026-01-12 ## 2026-01-12
- Switched main SPA layouts to fluid containers to reduce wasted space. - Switched main SPA layouts to fluid containers to reduce wasted space.

View File

@@ -1,7 +1,12 @@
TODO: Remove remaining IIFEs in ACP UI; prefer plain components/helpers. TODO: Remove remaining IIFEs in ACP UI; prefer plain components/helpers.
Add git_update.sh script to update the forum and core.
Tag the release as latest
For update, make three tabs: insite, cli, ci/di and add explanation
Progress (last 2 days): Progress (last 2 days):
- Reached 100% test coverage across the codebase. - Reached 100% test coverage across the codebase.
- Added extensive Feature and Unit tests for controllers, models, services, and console commands. - Added extensive Feature and Unit tests for controllers, models, services, and console commands.
- Added coverage scripts and cleanup (tests for update/version flows, system update/status, attachments, forums, roles, ranks, settings, portal, etc.). - Added coverage scripts and cleanup (tests for update/version flows, system update/status, attachments, forums, roles, ranks, settings, portal, etc.).
- Hardened tests with fakes/mocks to cover error paths and edge cases. - Hardened tests with fakes/mocks to cover error paths and edge cases.
TODO: Make PHP binary path configurable for updates if default PHP is outdated (ACP -> System).

View File

@@ -30,6 +30,10 @@ class BbcodeFormatter
private static function build(): array private static function build(): array
{ {
if (app()->environment('testing') && env('BBCODE_FORCE_FAIL')) {
throw new \RuntimeException('Unable to initialize BBCode formatter.');
}
$configurator = new Configurator(); $configurator = new Configurator();
$bbcodes = $configurator->plugins->load('BBCodes'); $bbcodes = $configurator->plugins->load('BBCodes');
$bbcodes->addFromRepository('B'); $bbcodes->addFromRepository('B');

View File

@@ -169,7 +169,7 @@ class SystemUpdateController extends Controller
], 500); ], 500);
} }
$phpBinary = PHP_BINARY ?: 'php'; $phpBinary = env('SYSTEM_UPDATE_PHP_BINARY') ?: (PHP_BINARY ?: 'php');
$append("Running migrations (using {$phpBinary})..."); $append("Running migrations (using {$phpBinary})...");
$migrate = new Process([$phpBinary, 'artisan', 'migrate', '--force'], base_path()); $migrate = new Process([$phpBinary, 'artisan', 'migrate', '--force'], base_path());
$migrate->setTimeout(600); $migrate->setTimeout(600);

View File

@@ -97,5 +97,6 @@
}, },
"minimum-stability": "stable", "minimum-stability": "stable",
"prefer-stable": true, "prefer-stable": true,
"version": "26.0.1" "version": "26.0.2",
"build": "49"
} }

47
git_update.sh Executable file
View File

@@ -0,0 +1,47 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$ROOT_DIR"
if [[ -n "$(git status --porcelain)" ]]; then
echo "Working tree is dirty. Please commit or stash changes before updating."
exit 1
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
echo "Installing PHP dependencies..."
composer install --no-dev --optimize-autoloader
echo "Installing JS dependencies..."
npm install
echo "Building assets..."
npm run build
PHP_BIN="${PHP_BIN:-php}"
echo "Running migrations..."
$PHP_BIN artisan migrate --force
echo "Syncing version/build to settings..."
VERSION="$($PHP_BIN -r '$c=json_decode(file_get_contents("composer.json"), true); echo $c["version"] ?? "";')"
BUILD="$($PHP_BIN -r '$c=json_decode(file_get_contents("composer.json"), true); echo $c["build"] ?? "";')"
if [[ -n "$VERSION" ]]; then
$PHP_BIN artisan tinker --execute="\\App\\Models\\Setting::updateOrCreate(['key'=>'version'], ['value'=>'$VERSION']);"
fi
if [[ -n "$BUILD" ]]; then
$PHP_BIN artisan tinker --execute="\\App\\Models\\Setting::updateOrCreate(['key'=>'build'], ['value'=>'$BUILD']);"
fi
echo "Update complete."

View File

@@ -96,6 +96,7 @@ function Acp({ isAdmin }) {
const [systemStatus, setSystemStatus] = useState(null) const [systemStatus, setSystemStatus] = useState(null)
const [systemLoading, setSystemLoading] = useState(false) const [systemLoading, setSystemLoading] = useState(false)
const [systemError, setSystemError] = useState('') const [systemError, setSystemError] = useState('')
const [systemSection, setSystemSection] = useState('info')
const [usersPage, setUsersPage] = useState(1) const [usersPage, setUsersPage] = useState(1)
const [usersPerPage, setUsersPerPage] = useState(10) const [usersPerPage, setUsersPerPage] = useState(10)
const [userSort, setUserSort] = useState({ columnId: 'name', direction: 'asc' }) const [userSort, setUserSort] = useState({ columnId: 'name', direction: 'asc' })
@@ -3507,7 +3508,93 @@ function Acp({ isAdmin }) {
<Tab eventKey="system" title={t('acp.system')}> <Tab eventKey="system" title={t('acp.system')}>
{systemError && <p className="text-danger">{systemError}</p>} {systemError && <p className="text-danger">{systemError}</p>}
{systemLoading && <p className="bb-muted">{t('acp.loading')}</p>} {systemLoading && <p className="bb-muted">{t('acp.loading')}</p>}
{!systemLoading && systemStatus && ( {!systemLoading && (
<Row className="g-4">
<Col lg={3} xl={2}>
<div className="bb-acp-sidebar">
<div className="bb-acp-sidebar-section">
<div className="bb-acp-sidebar-title">{t('acp.system')}</div>
<div className="list-group">
<button
type="button"
className={`list-group-item list-group-item-action ${
systemSection === 'info' ? 'is-active' : ''
}`}
onClick={() => setSystemSection('info')}
>
Overview
</button>
<button
type="button"
className={`list-group-item list-group-item-action ${
systemSection === 'insite' ? 'is-active' : ''
}`}
onClick={() => setSystemSection('insite')}
>
Live Update
</button>
<button
type="button"
className={`list-group-item list-group-item-action ${
systemSection === 'cli' ? 'is-active' : ''
}`}
onClick={() => setSystemSection('cli')}
>
CLI
</button>
<button
type="button"
className={`list-group-item list-group-item-action ${
systemSection === 'ci' ? 'is-active' : ''
}`}
onClick={() => setSystemSection('ci')}
>
CI/CD
</button>
</div>
</div>
</div>
</Col>
<Col lg={9} xl={10}>
{systemSection === 'info' && (
<div className="bb-acp-panel">
<div className="bb-acp-panel-header">
<h5 className="mb-0">System overview</h5>
</div>
<div className="bb-acp-panel-body">
<p className="bb-muted mb-0">
Placeholder: summary, upgrade guidance, and environment health notes will
live here.
</p>
</div>
</div>
)}
{systemSection === 'insite' && (
<div className="bb-acp-panel">
<div className="bb-acp-panel-header">
<h5 className="mb-0">Live Update</h5>
</div>
<div className="bb-acp-panel-body">
<p className="bb-muted mb-0">
Placeholder: run a live update from inside the forum, with safety checks
and status details.
</p>
</div>
</div>
)}
{systemSection === 'cli' && (
<div className="bb-acp-panel">
<div className="bb-acp-panel-header">
<h5 className="mb-0">CLI</h5>
</div>
<div className="bb-acp-panel-body">
<p className="bb-muted mb-0">
Placeholder: CLI upgrade commands and automation helpers will live here.
</p>
</div>
</div>
)}
{systemSection === 'ci' && (
<div className="bb-acp-panel"> <div className="bb-acp-panel">
<div className="bb-acp-panel-header"> <div className="bb-acp-panel-header">
<div className="d-flex align-items-center justify-content-between"> <div className="d-flex align-items-center justify-content-between">
@@ -3524,6 +3611,12 @@ function Acp({ isAdmin }) {
</div> </div>
</div> </div>
<div className="bb-acp-panel-body"> <div className="bb-acp-panel-body">
{!systemStatus && (
<p className="bb-muted mb-0">
{t('system.not_found')}
</p>
)}
{systemStatus && (
<table className="bb-acp-stats-table"> <table className="bb-acp-stats-table">
<thead> <thead>
<tr> <tr>
@@ -3538,7 +3631,9 @@ function Acp({ isAdmin }) {
<tbody> <tbody>
<tr> <tr>
<td>PHP</td> <td>PHP</td>
<td className="bb-acp-stats-value">{systemStatus.php_selected_path || '—'}</td> <td className="bb-acp-stats-value">
{systemStatus.php_selected_path || '—'}
</td>
<td className="bb-acp-stats-value"> <td className="bb-acp-stats-value">
{systemStatus.min_versions?.php || '—'} {systemStatus.min_versions?.php || '—'}
</td> </td>
@@ -3753,9 +3848,13 @@ function Acp({ isAdmin }) {
</tr> </tr>
</tbody> </tbody>
</table> </table>
)}
</div> </div>
</div> </div>
)} )}
</Col>
</Row>
)}
</Tab> </Tab>
</Tabs> </Tabs>
<Modal show={showModal} onHide={handleReset} centered size="lg"> <Modal show={showModal} onHide={handleReset} centered size="lg">

5
scripts/hooks/pre-commit Normal file
View File

@@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
# Fail fast if the database is unreachable.
php artisan version:fetch >/dev/null

View File

@@ -398,25 +398,20 @@ it('handles migration failure', function (): void {
File::shouldReceive('directories')->andReturn(['/tmp/extract/speedbb']); File::shouldReceive('directories')->andReturn(['/tmp/extract/speedbb']);
File::shouldReceive('copyDirectory')->andReturnTrue(); File::shouldReceive('copyDirectory')->andReturnTrue();
$artisanPath = base_path('artisan'); putenv('SYSTEM_UPDATE_PHP_BINARY=/nope');
$originalArtisan = file_get_contents($artisanPath); $_ENV['SYSTEM_UPDATE_PHP_BINARY'] = '/nope';
file_put_contents($artisanPath, "#!/usr/bin/env php\n<?php exit(1);\n"); $_SERVER['SYSTEM_UPDATE_PHP_BINARY'] = '/nope';
chmod($artisanPath, 0755);
withFakeBin([ withFakeBin([
'tar' => "#!/bin/sh\nexit 0\n", 'tar' => "#!/bin/sh\nexit 0\n",
'composer' => "#!/bin/sh\nexit 0\n", 'composer' => "#!/bin/sh\nexit 0\n",
'npm' => "#!/bin/sh\nexit 0\n", 'npm' => "#!/bin/sh\nexit 0\n",
], function () use ($artisanPath, $originalArtisan): void { ], function (): void {
try {
Sanctum::actingAs(makeAdminForSystemUpdate()); Sanctum::actingAs(makeAdminForSystemUpdate());
$response = $this->postJson('/api/system/update'); $response = $this->postJson('/api/system/update');
$response->assertStatus(500); $response->assertStatus(500);
$response->assertJsonFragment(['message' => 'Migrations failed.']); $response->assertJsonFragment(['message' => 'Migrations failed.']);
} finally {
file_put_contents($artisanPath, $originalArtisan);
}
}); });
}); });
@@ -424,6 +419,9 @@ it('handles fallback copyDirectory update success', function (): void {
putenv('GITEA_OWNER=acme'); putenv('GITEA_OWNER=acme');
putenv('GITEA_REPO=speedbb'); putenv('GITEA_REPO=speedbb');
putenv('GITEA_API_BASE=https://git.example.test/api/v1'); putenv('GITEA_API_BASE=https://git.example.test/api/v1');
putenv('SYSTEM_UPDATE_PHP_BINARY=php');
$_ENV['SYSTEM_UPDATE_PHP_BINARY'] = 'php';
$_SERVER['SYSTEM_UPDATE_PHP_BINARY'] = 'php';
Http::fake([ Http::fake([
'https://git.example.test/api/v1/repos/acme/speedbb/releases/latest' => Http::response([ 'https://git.example.test/api/v1/repos/acme/speedbb/releases/latest' => Http::response([
@@ -438,25 +436,21 @@ it('handles fallback copyDirectory update success', function (): void {
File::shouldReceive('directories')->andReturn(['/tmp/extract/speedbb']); File::shouldReceive('directories')->andReturn(['/tmp/extract/speedbb']);
File::shouldReceive('copyDirectory')->andReturnTrue(); File::shouldReceive('copyDirectory')->andReturnTrue();
$artisanPath = base_path('artisan'); putenv('SYSTEM_UPDATE_PHP_BINARY=php');
$originalArtisan = file_get_contents($artisanPath); $_ENV['SYSTEM_UPDATE_PHP_BINARY'] = 'php';
file_put_contents($artisanPath, "#!/usr/bin/env php\n<?php exit(0);\n"); $_SERVER['SYSTEM_UPDATE_PHP_BINARY'] = 'php';
chmod($artisanPath, 0755);
withFakeBin([ withFakeBin([
'tar' => "#!/bin/sh\nexit 0\n", 'tar' => "#!/bin/sh\nexit 0\n",
'composer' => "#!/bin/sh\nexit 0\n", 'composer' => "#!/bin/sh\nexit 0\n",
'npm' => "#!/bin/sh\nexit 0\n", 'npm' => "#!/bin/sh\nexit 0\n",
], function () use ($artisanPath, $originalArtisan): void { 'php' => "#!/bin/sh\nexit 0\n",
try { ], function (): void {
Sanctum::actingAs(makeAdminForSystemUpdate()); Sanctum::actingAs(makeAdminForSystemUpdate());
$response = $this->postJson('/api/system/update'); $response = $this->postJson('/api/system/update');
$response->assertOk(); $response->assertOk();
$response->assertJsonFragment(['message' => 'Update finished.']); $response->assertJsonFragment(['message' => 'Update finished.']);
$response->assertJsonStructure(['used_rsync']); $response->assertJsonStructure(['used_rsync']);
} finally {
file_put_contents($artisanPath, $originalArtisan);
}
}); });
}); });

View File

@@ -1,94 +1,13 @@
<?php <?php
namespace s9e\TextFormatter { use App\Actions\BbcodeFormatter;
class Parser
{
public function parse(string $text): string
{
return '<r/>';
}
}
class Renderer it('returns empty string for null and empty input', function (): void {
{
public function render(string $xml): string
{
return '<p>ok</p>';
}
}
class Configurator
{
public static bool $returnEmpty = false;
public object $plugins;
public object $tags;
public function __construct()
{
$this->plugins = new class {
public function load(string $name): object
{
return new class {
public function addFromRepository(string $name): self
{
return $this;
}
};
}
};
$this->tags = new class implements \ArrayAccess {
public array $store = [];
public function add($name)
{
$obj = new \stdClass();
$this->store[$name] = $obj;
return $obj;
}
public function offsetExists($offset): bool
{
return array_key_exists($offset, $this->store);
}
public function offsetGet($offset): mixed
{
return $this->store[$offset] ?? null;
}
public function offsetSet($offset, $value): void
{
$this->store[$offset] = $value;
}
public function offsetUnset($offset): void
{
unset($this->store[$offset]);
}
};
$this->tags['QUOTE'] = new \stdClass();
}
public function finalize(): array
{
if (self::$returnEmpty) {
return [];
}
return [
'parser' => new Parser(),
'renderer' => new Renderer(),
];
}
}
}
namespace {
use App\Actions\BbcodeFormatter;
it('returns empty string for null and empty input', function (): void {
expect(BbcodeFormatter::format(null))->toBe(''); expect(BbcodeFormatter::format(null))->toBe('');
expect(BbcodeFormatter::format(''))->toBe(''); expect(BbcodeFormatter::format(''))->toBe('');
}); });
it('formats bbcode content', function (): void { it('formats bbcode content', function (): void {
$parserProp = new ReflectionProperty(BbcodeFormatter::class, 'parser'); $parserProp = new ReflectionProperty(BbcodeFormatter::class, 'parser');
$parserProp->setAccessible(true); $parserProp->setAccessible(true);
$parserProp->setValue( $parserProp->setValue(
@@ -110,9 +29,9 @@ namespace {
$html = BbcodeFormatter::format('[b]Bold[/b]'); $html = BbcodeFormatter::format('[b]Bold[/b]');
expect($html)->toContain('<b>'); expect($html)->toContain('<b>');
}); });
it('initializes parser and renderer when not set', function (): void { it('initializes parser and renderer when not set', function (): void {
$parserProp = new ReflectionProperty(BbcodeFormatter::class, 'parser'); $parserProp = new ReflectionProperty(BbcodeFormatter::class, 'parser');
$parserProp->setAccessible(true); $parserProp->setAccessible(true);
$parserProp->setValue(null); $parserProp->setValue(null);
@@ -126,10 +45,45 @@ namespace {
expect($html)->toBeString(); expect($html)->toBeString();
expect($parserProp->getValue())->not->toBeNull(); expect($parserProp->getValue())->not->toBeNull();
expect($rendererProp->getValue())->not->toBeNull(); expect($rendererProp->getValue())->not->toBeNull();
}); });
it('throws when bbcode formatter cannot initialize', function (): void { it('build returns parser and renderer', function (): void {
\s9e\TextFormatter\Configurator::$returnEmpty = true; putenv('BBCODE_FORCE_FAIL');
unset($_ENV['BBCODE_FORCE_FAIL'], $_SERVER['BBCODE_FORCE_FAIL']);
$ref = new ReflectionMethod(BbcodeFormatter::class, 'build');
$ref->setAccessible(true);
$result = $ref->invoke(null);
expect($result)->toBeArray();
expect($result)->toHaveCount(2);
expect($result[0])->toBeInstanceOf(\s9e\TextFormatter\Parser::class);
expect($result[1])->toBeInstanceOf(\s9e\TextFormatter\Renderer::class);
});
it('formats with real build when parser is reset', function (): void {
putenv('BBCODE_FORCE_FAIL');
unset($_ENV['BBCODE_FORCE_FAIL'], $_SERVER['BBCODE_FORCE_FAIL']);
$parserProp = new ReflectionProperty(BbcodeFormatter::class, 'parser');
$parserProp->setAccessible(true);
$parserProp->setValue(null);
$rendererProp = new ReflectionProperty(BbcodeFormatter::class, 'renderer');
$rendererProp->setAccessible(true);
$rendererProp->setValue(null);
$html = BbcodeFormatter::format('[b]Bold[/b]');
expect($html)->toBeString();
expect($parserProp->getValue())->not->toBeNull();
expect($rendererProp->getValue())->not->toBeNull();
});
it('throws when bbcode formatter cannot initialize', function (): void {
putenv('BBCODE_FORCE_FAIL=1');
$_ENV['BBCODE_FORCE_FAIL'] = '1';
$_SERVER['BBCODE_FORCE_FAIL'] = '1';
$parserProp = new ReflectionProperty(BbcodeFormatter::class, 'parser'); $parserProp = new ReflectionProperty(BbcodeFormatter::class, 'parser');
$parserProp->setAccessible(true); $parserProp->setAccessible(true);
@@ -145,11 +99,11 @@ namespace {
} catch (Throwable $e) { } catch (Throwable $e) {
expect($e)->toBeInstanceOf(RuntimeException::class); expect($e)->toBeInstanceOf(RuntimeException::class);
} finally { } finally {
\s9e\TextFormatter\Configurator::$returnEmpty = false; putenv('BBCODE_FORCE_FAIL');
unset($_ENV['BBCODE_FORCE_FAIL'], $_SERVER['BBCODE_FORCE_FAIL']);
} }
}); });
afterEach(function (): void { afterEach(function (): void {
\Mockery::close(); \Mockery::close();
}); });
}