release aufiting
This commit is contained in:
@@ -0,0 +1,211 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\Artisan;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
use JsonException;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
class ReleaseAuditCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'release:audit
|
||||||
|
{--production : Apply production-readiness checks}
|
||||||
|
{--fail-on-warning : Return a failure exit code when warnings exist}';
|
||||||
|
|
||||||
|
protected $description = 'Audit Content Manager for v1.0 release readiness';
|
||||||
|
|
||||||
|
/** @var array<int, string> */
|
||||||
|
private array $failures = [];
|
||||||
|
|
||||||
|
/** @var array<int, string> */
|
||||||
|
private array $warnings = [];
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$this->components->info('Content Manager '.config('release.version').' release audit');
|
||||||
|
|
||||||
|
$this->checkApplication();
|
||||||
|
$this->checkDatabase();
|
||||||
|
$this->checkModules();
|
||||||
|
$this->checkRoutes();
|
||||||
|
$this->checkProductionConfiguration();
|
||||||
|
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
foreach ($this->warnings as $warning) {
|
||||||
|
$this->components->warn($warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($this->failures as $failure) {
|
||||||
|
$this->components->error($failure);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->failures !== []) {
|
||||||
|
$this->components->error(sprintf(
|
||||||
|
'Release audit failed with %d error(s) and %d warning(s).',
|
||||||
|
count($this->failures),
|
||||||
|
count($this->warnings),
|
||||||
|
));
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->option('fail-on-warning') && $this->warnings !== []) {
|
||||||
|
$this->components->error('Release audit contains warnings and --fail-on-warning was supplied.');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->components->success(sprintf(
|
||||||
|
'Release audit passed with %d warning(s).',
|
||||||
|
count($this->warnings),
|
||||||
|
));
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function checkApplication(): void
|
||||||
|
{
|
||||||
|
$this->check((bool) config('app.key'), 'Application key is configured.');
|
||||||
|
$this->check(is_writable(storage_path()), 'Storage directory is writable.');
|
||||||
|
$this->check(is_writable(base_path('bootstrap/cache')), 'Bootstrap cache directory is writable.');
|
||||||
|
|
||||||
|
try {
|
||||||
|
Artisan::call('about', ['--only' => 'environment']);
|
||||||
|
$this->pass('Laravel boots successfully.');
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
$this->fail('Laravel failed to boot: '.$exception->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function checkDatabase(): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
DB::connection()->getPdo();
|
||||||
|
$this->pass('Database connection is available.');
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
$this->fail('Database connection failed: '.$exception->getMessage());
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! Schema::hasTable('migrations')) {
|
||||||
|
$this->fail('The migrations table does not exist.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
Artisan::call('migrate:status', ['--no-interaction' => true]);
|
||||||
|
$output = Artisan::output();
|
||||||
|
|
||||||
|
if (str_contains($output, 'Pending')) {
|
||||||
|
$this->fail('One or more database migrations are pending.');
|
||||||
|
} else {
|
||||||
|
$this->pass('No pending database migrations were detected.');
|
||||||
|
}
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
$this->fail('Migration status check failed: '.$exception->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function checkModules(): void
|
||||||
|
{
|
||||||
|
$modulePath = base_path('Modules');
|
||||||
|
|
||||||
|
if (! is_dir($modulePath)) {
|
||||||
|
$this->fail('Modules directory is missing.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$available = [];
|
||||||
|
|
||||||
|
foreach (glob($modulePath.'/*/module.json') ?: [] as $manifest) {
|
||||||
|
try {
|
||||||
|
$data = json_decode((string) file_get_contents($manifest), true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
$name = $data['name'] ?? basename(dirname($manifest));
|
||||||
|
$available[] = $name;
|
||||||
|
$this->pass("Module manifest is valid: {$name}.");
|
||||||
|
} catch (JsonException $exception) {
|
||||||
|
$this->fail(sprintf('Invalid module manifest %s: %s', $manifest, $exception->getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (config('release.required_modules', []) as $required) {
|
||||||
|
if (! in_array($required, $available, true)) {
|
||||||
|
$this->warn("Expected module is not present: {$required}.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function checkRoutes(): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$routes = Route::getRoutes();
|
||||||
|
$names = [];
|
||||||
|
|
||||||
|
foreach ($routes as $route) {
|
||||||
|
$name = $route->getName();
|
||||||
|
|
||||||
|
if ($name === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($names[$name])) {
|
||||||
|
$this->fail("Duplicate named route detected: {$name}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
$names[$name] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->pass(sprintf('Route registry loaded with %d route(s).', count($routes)));
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
$this->fail('Route audit failed: '.$exception->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function checkProductionConfiguration(): void
|
||||||
|
{
|
||||||
|
if (! $this->option('production')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->check(app()->environment('production'), 'APP_ENV is production.');
|
||||||
|
$this->check(config('app.debug') === false, 'APP_DEBUG is disabled.');
|
||||||
|
$this->check(config('app.url') !== null, 'APP_URL is configured.');
|
||||||
|
$this->check(config('session.secure') === true, 'Secure session cookies are enabled.', warning: true);
|
||||||
|
$this->check(config('marketplace.allow_remote_install', false) === false, 'Remote marketplace installation is disabled.', warning: true);
|
||||||
|
$this->check(config('cloud-devops.allow_execution', false) === false, 'Cloud deployment execution is disabled until explicitly configured.', warning: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function check(bool $condition, string $message, bool $warning = false): void
|
||||||
|
{
|
||||||
|
if ($condition) {
|
||||||
|
$this->pass($message);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$warning ? $this->warn($message) : $this->fail($message);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function pass(string $message): void
|
||||||
|
{
|
||||||
|
$this->components->twoColumnDetail($message, '<fg=green>PASS</>');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function warn(string $message): void
|
||||||
|
{
|
||||||
|
$this->warnings[] = $message;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function fail(string $message): void
|
||||||
|
{
|
||||||
|
$this->failures[] = $message;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'version' => env('CONTENT_MANAGER_VERSION', '1.0.0-rc.1'),
|
||||||
|
|
||||||
|
'required_modules' => [
|
||||||
|
'Core', 'Administration', 'Users', 'Roles', 'Settings', 'Audit',
|
||||||
|
'Media', 'SEO', 'Search', 'Pages', 'Menus', 'Redirects', 'Forms',
|
||||||
|
'Documents', 'Banners', 'FAQ', 'Team', 'Testimonials', 'Blog',
|
||||||
|
'News', 'Categories', 'Tags', 'Authors', 'Comments', 'RSS',
|
||||||
|
'Sitemap', 'CRM', 'Newsletter', 'Events', 'Careers', 'Locations',
|
||||||
|
'Workflow', 'Localization', 'Sites', 'Developer', 'Api',
|
||||||
|
'Enterprise', 'Commerce', 'Security', 'Automation', 'AI',
|
||||||
|
'Analytics', 'Builder', 'Marketplace', 'CloudDevOps',
|
||||||
|
],
|
||||||
|
|
||||||
|
'production' => [
|
||||||
|
'app_debug' => false,
|
||||||
|
'app_env' => 'production',
|
||||||
|
'allow_marketplace_remote_install' => false,
|
||||||
|
'allow_cloud_devops_execution' => false,
|
||||||
|
],
|
||||||
|
];
|
||||||
Executable
+12
@@ -0,0 +1,12 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
php artisan optimize:clear
|
||||||
|
composer validate --no-check-publish
|
||||||
|
composer dump-autoload --optimize
|
||||||
|
php artisan migrate:status
|
||||||
|
php artisan route:list --except-vendor
|
||||||
|
php artisan release:audit --fail-on-warning
|
||||||
|
php artisan test
|
||||||
|
|
||||||
|
printf '\nRelease-candidate audit completed successfully.\n'
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Blade;
|
||||||
|
|
||||||
|
it('compiles application and module blade templates', function (): void {
|
||||||
|
$views = array_merge(
|
||||||
|
glob(resource_path('views/**/*.blade.php')) ?: [],
|
||||||
|
glob(base_path('Modules/*/Resources/views/**/*.blade.php')) ?: [],
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach ($views as $view) {
|
||||||
|
$compiled = Blade::compileString((string) file_get_contents($view));
|
||||||
|
|
||||||
|
expect($compiled)->toBeString()->not->toBeEmpty();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
it('does not contain duplicate migration filenames', function (): void {
|
||||||
|
$files = array_merge(
|
||||||
|
glob(database_path('migrations/*.php')) ?: [],
|
||||||
|
glob(base_path('Modules/*/Database/Migrations/*.php')) ?: [],
|
||||||
|
);
|
||||||
|
|
||||||
|
$duplicates = collect($files)
|
||||||
|
->groupBy(fn (string $file): string => basename($file))
|
||||||
|
->filter(fn ($group): bool => $group->count() > 1)
|
||||||
|
->keys()
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
expect($duplicates)->toBe([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses unique migration class names', function (): void {
|
||||||
|
$files = array_merge(
|
||||||
|
glob(database_path('migrations/*.php')) ?: [],
|
||||||
|
glob(base_path('Modules/*/Database/Migrations/*.php')) ?: [],
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach ($files as $file) {
|
||||||
|
expect((string) file_get_contents($file))->not->toContain('class CreateMigration');
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
it('has valid module manifests', function (): void {
|
||||||
|
$manifests = glob(base_path('Modules/*/module.json')) ?: [];
|
||||||
|
|
||||||
|
expect($manifests)->not->toBeEmpty();
|
||||||
|
|
||||||
|
foreach ($manifests as $manifest) {
|
||||||
|
$decoded = json_decode((string) file_get_contents($manifest), true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
|
||||||
|
expect($decoded)
|
||||||
|
->toBeArray()
|
||||||
|
->toHaveKey('name')
|
||||||
|
->toHaveKey('providers');
|
||||||
|
|
||||||
|
expect($decoded['name'])->toBeString()->not->toBeEmpty();
|
||||||
|
expect($decoded['providers'])->toBeArray();
|
||||||
|
|
||||||
|
foreach ($decoded['providers'] as $provider) {
|
||||||
|
expect($provider)->toBeString();
|
||||||
|
expect(Str::contains($provider, '\\'))->toBeTrue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
|
it('does not register duplicate named routes', function (): void {
|
||||||
|
$names = collect(Route::getRoutes())
|
||||||
|
->map(fn ($route): ?string => $route->getName())
|
||||||
|
->filter()
|
||||||
|
->values();
|
||||||
|
|
||||||
|
expect($names->duplicates()->values()->all())->toBe([]);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user