From c91ac80ab6a34eefc16c29ca609681f5f5b7b340 Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Fri, 24 Jul 2026 14:04:37 +0100 Subject: [PATCH] release aufiting --- app/Console/Commands/ReleaseAuditCommand.php | 211 ++++++++++++++++++ config/release.php | 23 ++ scripts/release-audit.sh | 12 + .../Feature/Release/BladeCompilationTest.php | 16 ++ .../Release/MigrationIntegrityTest.php | 28 +++ .../Release/ModuleManifestIntegrityTest.php | 26 +++ .../Release/RouteNameIntegrityTest.php | 12 + 7 files changed, 328 insertions(+) create mode 100644 app/Console/Commands/ReleaseAuditCommand.php create mode 100644 config/release.php create mode 100755 scripts/release-audit.sh create mode 100644 tests/Feature/Release/BladeCompilationTest.php create mode 100644 tests/Feature/Release/MigrationIntegrityTest.php create mode 100644 tests/Feature/Release/ModuleManifestIntegrityTest.php create mode 100644 tests/Feature/Release/RouteNameIntegrityTest.php diff --git a/app/Console/Commands/ReleaseAuditCommand.php b/app/Console/Commands/ReleaseAuditCommand.php new file mode 100644 index 0000000..77436b0 --- /dev/null +++ b/app/Console/Commands/ReleaseAuditCommand.php @@ -0,0 +1,211 @@ + */ + private array $failures = []; + + /** @var array */ + 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, 'PASS'); + } + + private function warn(string $message): void + { + $this->warnings[] = $message; + } + + private function fail(string $message): void + { + $this->failures[] = $message; + } +} diff --git a/config/release.php b/config/release.php new file mode 100644 index 0000000..1fcd13a --- /dev/null +++ b/config/release.php @@ -0,0 +1,23 @@ + 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, + ], +]; diff --git a/scripts/release-audit.sh b/scripts/release-audit.sh new file mode 100755 index 0000000..8650f76 --- /dev/null +++ b/scripts/release-audit.sh @@ -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' diff --git a/tests/Feature/Release/BladeCompilationTest.php b/tests/Feature/Release/BladeCompilationTest.php new file mode 100644 index 0000000..898bb42 --- /dev/null +++ b/tests/Feature/Release/BladeCompilationTest.php @@ -0,0 +1,16 @@ +toBeString()->not->toBeEmpty(); + } +}); diff --git a/tests/Feature/Release/MigrationIntegrityTest.php b/tests/Feature/Release/MigrationIntegrityTest.php new file mode 100644 index 0000000..dd20781 --- /dev/null +++ b/tests/Feature/Release/MigrationIntegrityTest.php @@ -0,0 +1,28 @@ +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'); + } +}); diff --git a/tests/Feature/Release/ModuleManifestIntegrityTest.php b/tests/Feature/Release/ModuleManifestIntegrityTest.php new file mode 100644 index 0000000..1a59d97 --- /dev/null +++ b/tests/Feature/Release/ModuleManifestIntegrityTest.php @@ -0,0 +1,26 @@ +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(); + } + } +}); diff --git a/tests/Feature/Release/RouteNameIntegrityTest.php b/tests/Feature/Release/RouteNameIntegrityTest.php new file mode 100644 index 0000000..446faa9 --- /dev/null +++ b/tests/Feature/Release/RouteNameIntegrityTest.php @@ -0,0 +1,12 @@ +map(fn ($route): ?string => $route->getName()) + ->filter() + ->values(); + + expect($names->duplicates()->values()->all())->toBe([]); +});