diff --git a/src/Application.php b/src/Application.php
index b4fa578..cdec268 100644
--- a/src/Application.php
+++ b/src/Application.php
@@ -11,6 +11,8 @@
namespace Enabel\CodingStandard;
+use Enabel\CodingStandard\Command\CiAddCommand;
+use Enabel\CodingStandard\Command\CiUpdateCommand;
use Enabel\CodingStandard\Command\InitCommand;
use Symfony\Component\Console\Application as BaseApplication;
@@ -24,6 +26,8 @@ public function __construct()
parent::__construct(self::NAME, self::VERSION);
$this->addCommand(new InitCommand());
+ $this->addCommand(new CiUpdateCommand());
+ $this->addCommand(new CiAddCommand());
$this->setDefaultCommand('init');
}
}
diff --git a/src/Command/CiAddCommand.php b/src/Command/CiAddCommand.php
new file mode 100644
index 0000000..a68c455
--- /dev/null
+++ b/src/Command/CiAddCommand.php
@@ -0,0 +1,106 @@
+
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Enabel\CodingStandard\Command;
+
+use Enabel\CodingStandard\Detector\ProjectDetector;
+use Symfony\Component\Console\Attribute\AsCommand;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+use Symfony\Component\Console\Style\SymfonyStyle;
+use Symfony\Component\Filesystem\Filesystem;
+
+#[AsCommand(
+ name: 'ci:add',
+ description: 'Add CI configuration for a new provider',
+)]
+final class CiAddCommand extends Command
+{
+ private const array VALID_PROVIDERS = ['gitlab', 'github', 'azure'];
+
+ protected function configure(): void
+ {
+ CiCommandHelper::addCiOptions($this);
+
+ $this
+ ->addOption('provider', null, InputOption::VALUE_REQUIRED, 'CI provider (gitlab, github, azure)')
+ ->addOption('force', 'f', InputOption::VALUE_NONE, 'Overwrite if provider already exists');
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int
+ {
+ $io = new SymfonyStyle($input, $output);
+ $filesystem = new Filesystem();
+
+ $outputDir = $input->getOption('output-dir');
+ if (!\is_string($outputDir)) {
+ $outputDir = '.';
+ }
+ $outputDir = realpath($outputDir) ?: $outputDir;
+
+ $provider = $input->getOption('provider');
+
+ if (!\is_string($provider) || '' === $provider) {
+ if ($input->isInteractive()) {
+ /** @var string $provider */
+ $provider = $io->choice('Which CI provider?', self::VALID_PROVIDERS);
+ } else {
+ $io->error('The --provider option is required in non-interactive mode.');
+
+ return Command::FAILURE;
+ }
+ }
+
+ if (!\in_array($provider, self::VALID_PROVIDERS, true)) {
+ $io->error(sprintf('Invalid provider "%s". Valid providers: %s', (string) $provider, implode(', ', self::VALID_PROVIDERS)));
+
+ return Command::FAILURE;
+ }
+
+ $detector = new ProjectDetector($outputDir);
+ $existingProviders = $detector->detectCiProviders();
+ $force = (bool) $input->getOption('force');
+
+ if (\in_array($provider, $existingProviders, true) && !$force) {
+ $io->error(sprintf('CI configuration for "%s" already exists. Use --force to overwrite.', $provider));
+
+ return Command::FAILURE;
+ }
+
+ $helper = new CiCommandHelper();
+ $files = $helper->generateCiFiles($input, $outputDir, $detector, [$provider]);
+
+ $writtenCount = 0;
+ foreach ($files as $relativePath => $content) {
+ $fullPath = $outputDir . '/' . $relativePath;
+ $directory = \dirname($fullPath);
+
+ if (!is_dir($directory)) {
+ $filesystem->mkdir($directory);
+ }
+
+ $filesystem->dumpFile($fullPath, $content);
+ $io->writeln(sprintf(' Created: %s', $relativePath));
+ ++$writtenCount;
+ }
+
+ $io->newLine();
+ $io->success(sprintf(
+ 'CI configuration for %s added. %d files written.',
+ $provider,
+ $writtenCount,
+ ));
+
+ return Command::SUCCESS;
+ }
+}
diff --git a/src/Command/CiCommandHelper.php b/src/Command/CiCommandHelper.php
new file mode 100644
index 0000000..2523918
--- /dev/null
+++ b/src/Command/CiCommandHelper.php
@@ -0,0 +1,153 @@
+
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Enabel\CodingStandard\Command;
+
+use Enabel\CodingStandard\Config\Configuration;
+use Enabel\CodingStandard\Config\ConflictResolution;
+use Enabel\CodingStandard\Detector\ProjectDetector;
+use Enabel\CodingStandard\Generator\AzureDevOpsGenerator;
+use Enabel\CodingStandard\Generator\GeneratorInterface;
+use Enabel\CodingStandard\Generator\GitHubActionsGenerator;
+use Enabel\CodingStandard\Generator\GitLabCiGenerator;
+use Enabel\CodingStandard\Template\TemplateRenderer;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+
+final class CiCommandHelper
+{
+ public static function addCiOptions(Command $command): void
+ {
+ $command
+ ->addOption('php-version', null, InputOption::VALUE_REQUIRED, 'PHP version (8.3, 8.4, 8.5)')
+ ->addOption('symfony', null, InputOption::VALUE_REQUIRED, 'Symfony version (7.4, 8.0) or "no"')
+ ->addOption('database', null, InputOption::VALUE_REQUIRED, 'Database type (mariadb, mysql, postgresql)')
+ ->addOption('database-version', null, InputOption::VALUE_REQUIRED, 'Database version')
+ ->addOption('php-cs-fixer', null, InputOption::VALUE_NEGATABLE, 'Include PHP-CS-Fixer')
+ ->addOption('phpstan', null, InputOption::VALUE_NEGATABLE, 'Include PHPStan')
+ ->addOption('rector', null, InputOption::VALUE_NEGATABLE, 'Include Rector')
+ ->addOption('output-dir', 'o', InputOption::VALUE_REQUIRED, 'Output directory', '.');
+ }
+
+ public function buildConfiguration(InputInterface $input, string $outputDir, ProjectDetector $detector, string $ciProvider): Configuration
+ {
+ $projectName = $detector->getProjectName() ?? basename($outputDir);
+
+ $phpVersion = $this->resolveOption($input, 'php-version') ?? $detector->getPhpVersion() ?? '8.4';
+
+ $symfony = $this->resolveOption($input, 'symfony');
+ if (null === $symfony) {
+ $isSymfony = $detector->isSymfonyProject();
+ $symfonyVersion = $isSymfony ? ($detector->getSymfonyVersion() ?? '8.0') : null;
+ } else {
+ $isSymfony = 'no' !== $symfony;
+ $symfonyVersion = $isSymfony ? $symfony : null;
+ }
+
+ $databaseType = $this->resolveOption($input, 'database');
+ $databaseVersion = $this->resolveOption($input, 'database-version');
+
+ if (null === $databaseType) {
+ $dbInfo = $detector->detectDatabase();
+ if (null !== $dbInfo) {
+ $databaseType = $dbInfo['type'];
+ $databaseVersion ??= $dbInfo['version'];
+ }
+ }
+
+ $includePhpCsFixer = $this->resolveNegatableOption($input, 'php-cs-fixer') ?? $detector->hasPhpCsFixer();
+ $includePhpStan = $this->resolveNegatableOption($input, 'phpstan') ?? $detector->hasPhpStan();
+ $includeRector = $this->resolveNegatableOption($input, 'rector') ?? $detector->hasRector();
+
+ return new Configuration(
+ projectName: $projectName,
+ phpVersion: $phpVersion,
+ phpstanLevel: 9,
+ isSymfonyProject: $isSymfony,
+ symfonyVersion: $symfonyVersion,
+ ciProvider: $ciProvider,
+ devEnvironment: 'local',
+ includeMakefile: false,
+ includePhpCsFixer: $includePhpCsFixer,
+ includePhpStan: $includePhpStan,
+ includeRector: $includeRector,
+ includePhpUnit: $detector->hasPhpUnit(),
+ srcPath: 'src',
+ testsPath: 'tests',
+ outputDir: $outputDir,
+ conflictResolution: ConflictResolution::REPLACE,
+ databaseType: $databaseType,
+ databaseVersion: $databaseVersion,
+ );
+ }
+
+ /**
+ * @param list $providers
+ *
+ * @return array Map of relative file paths to content
+ */
+ public function generateCiFiles(InputInterface $input, string $outputDir, ProjectDetector $detector, array $providers): array
+ {
+ $templateRenderer = new TemplateRenderer($this->getTemplatesPath());
+ $generators = $this->createCiGenerators($templateRenderer);
+ $files = [];
+
+ foreach ($providers as $provider) {
+ $config = $this->buildConfiguration($input, $outputDir, $detector, $provider);
+
+ foreach ($generators as $generator) {
+ if ($generator->supports($config)) {
+ $files = array_merge($files, $generator->generate($config));
+ }
+ }
+ }
+
+ return $files;
+ }
+
+ /**
+ * @return list
+ */
+ private function createCiGenerators(TemplateRenderer $renderer): array
+ {
+ return [
+ new GitLabCiGenerator($renderer),
+ new GitHubActionsGenerator($renderer),
+ new AzureDevOpsGenerator($renderer),
+ ];
+ }
+
+ private function getTemplatesPath(): string
+ {
+ return dirname(__DIR__, 2) . '/templates';
+ }
+
+ private function resolveOption(InputInterface $input, string $name): ?string
+ {
+ if (!$input->hasParameterOption('--' . $name)) {
+ return null;
+ }
+
+ $value = $input->getOption($name);
+
+ return \is_string($value) && '' !== $value ? $value : null;
+ }
+
+ private function resolveNegatableOption(InputInterface $input, string $name): ?bool
+ {
+ if (!$input->hasParameterOption('--' . $name) && !$input->hasParameterOption('--no-' . $name)) {
+ return null;
+ }
+
+ return (bool) $input->getOption($name);
+ }
+}
diff --git a/src/Command/CiUpdateCommand.php b/src/Command/CiUpdateCommand.php
new file mode 100644
index 0000000..6a272e1
--- /dev/null
+++ b/src/Command/CiUpdateCommand.php
@@ -0,0 +1,79 @@
+
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Enabel\CodingStandard\Command;
+
+use Enabel\CodingStandard\Detector\ProjectDetector;
+use Symfony\Component\Console\Attribute\AsCommand;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+use Symfony\Component\Console\Style\SymfonyStyle;
+use Symfony\Component\Filesystem\Filesystem;
+
+#[AsCommand(
+ name: 'ci:update',
+ description: 'Update existing CI configuration',
+)]
+final class CiUpdateCommand extends Command
+{
+ protected function configure(): void
+ {
+ CiCommandHelper::addCiOptions($this);
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int
+ {
+ $io = new SymfonyStyle($input, $output);
+ $filesystem = new Filesystem();
+
+ $outputDir = $input->getOption('output-dir');
+ if (!\is_string($outputDir)) {
+ $outputDir = '.';
+ }
+ $outputDir = realpath($outputDir) ?: $outputDir;
+
+ $detector = new ProjectDetector($outputDir);
+ $providers = $detector->detectCiProviders();
+
+ if ([] === $providers) {
+ $io->error('No existing CI configuration detected. Use ci:add to add a new CI provider.');
+
+ return Command::FAILURE;
+ }
+
+ $helper = new CiCommandHelper();
+ $files = $helper->generateCiFiles($input, $outputDir, $detector, $providers);
+
+ $writtenCount = 0;
+ foreach ($files as $relativePath => $content) {
+ $fullPath = $outputDir . '/' . $relativePath;
+ $directory = \dirname($fullPath);
+
+ if (!is_dir($directory)) {
+ $filesystem->mkdir($directory);
+ }
+
+ $filesystem->dumpFile($fullPath, $content);
+ $io->writeln(sprintf(' Updated: %s', $relativePath));
+ ++$writtenCount;
+ }
+
+ $io->newLine();
+ $io->success(sprintf(
+ 'CI configuration updated for %s. %d files written.',
+ implode(', ', $providers),
+ $writtenCount,
+ ));
+
+ return Command::SUCCESS;
+ }
+}
diff --git a/src/Detector/ProjectDetector.php b/src/Detector/ProjectDetector.php
index c412b33..3b4341f 100644
--- a/src/Detector/ProjectDetector.php
+++ b/src/Detector/ProjectDetector.php
@@ -13,11 +13,83 @@
final readonly class ProjectDetector
{
+ private const array CI_PROVIDER_FILES = [
+ 'gitlab' => '.gitlab-ci.yml',
+ 'github' => '.github/workflows/ci.yml',
+ 'azure' => 'azure-pipelines.yml',
+ ];
+
public function __construct(
private string $projectDir,
) {
}
+ /**
+ * @return list CI providers detected ('gitlab', 'github', 'azure')
+ */
+ public function detectCiProviders(): array
+ {
+ $providers = [];
+
+ foreach (self::CI_PROVIDER_FILES as $provider => $file) {
+ if (file_exists($this->projectDir . '/' . $file)) {
+ $providers[] = $provider;
+ }
+ }
+
+ return $providers;
+ }
+
+ /**
+ * @return array{type: string, version: string}|null
+ */
+ public function detectDatabase(): ?array
+ {
+ $composePath = $this->projectDir . '/compose.yaml';
+ if (!file_exists($composePath)) {
+ return null;
+ }
+
+ $content = file_get_contents($composePath);
+ if (false === $content) {
+ return null;
+ }
+
+ $patterns = [
+ 'mariadb' => '/image:\s*mariadb:(\d+\.\d+)/',
+ 'mysql' => '/image:\s*mysql:(\d+\.\d+)/',
+ 'postgresql' => '/image:\s*postgres:(\d+)/',
+ ];
+
+ foreach ($patterns as $type => $pattern) {
+ if (preg_match($pattern, $content, $matches)) {
+ return ['type' => $type, 'version' => $matches[1]];
+ }
+ }
+
+ return null;
+ }
+
+ public function hasPhpCsFixer(): bool
+ {
+ return file_exists($this->projectDir . '/.php-cs-fixer.dist.php');
+ }
+
+ public function hasPhpStan(): bool
+ {
+ return file_exists($this->projectDir . '/phpstan.neon');
+ }
+
+ public function hasRector(): bool
+ {
+ return file_exists($this->projectDir . '/rector.php');
+ }
+
+ public function hasPhpUnit(): bool
+ {
+ return file_exists($this->projectDir . '/phpunit.dist.xml');
+ }
+
public function getProjectName(): ?string
{
$composerJson = $this->readComposerJson();
diff --git a/src/Generator/GitHubActionsGenerator.php b/src/Generator/GitHubActionsGenerator.php
index 683eb7f..815d473 100644
--- a/src/Generator/GitHubActionsGenerator.php
+++ b/src/Generator/GitHubActionsGenerator.php
@@ -17,21 +17,24 @@ final class GitHubActionsGenerator extends AbstractGenerator
{
public function generate(Configuration $config): array
{
+ $variables = [
+ 'phpVersion' => $config->phpVersion,
+ 'isSymfony' => $config->isSymfonyProject,
+ 'includePhpCsFixer' => $config->includePhpCsFixer,
+ 'includePhpStan' => $config->includePhpStan,
+ 'includeRector' => $config->includeRector,
+ 'hasDatabase' => $config->hasDatabase(),
+ 'databaseType' => $config->databaseType,
+ 'databaseImage' => $config->getDatabaseImage(),
+ 'databasePort' => $config->getDatabasePort(),
+ 'databaseUrl' => $config->getDatabaseUrl(),
+ 'databaseEnvVars' => $config->getDatabaseEnvVars(),
+ 'phpDatabaseExtension' => $config->getPhpDatabaseExtension(),
+ ];
+
return [
- '.github/workflows/ci.yml' => $this->render('ci/github/workflows/ci.yml.tpl', [
- 'phpVersion' => $config->phpVersion,
- 'isSymfony' => $config->isSymfonyProject,
- 'includePhpCsFixer' => $config->includePhpCsFixer,
- 'includePhpStan' => $config->includePhpStan,
- 'includeRector' => $config->includeRector,
- 'hasDatabase' => $config->hasDatabase(),
- 'databaseType' => $config->databaseType,
- 'databaseImage' => $config->getDatabaseImage(),
- 'databasePort' => $config->getDatabasePort(),
- 'databaseUrl' => $config->getDatabaseUrl(),
- 'databaseEnvVars' => $config->getDatabaseEnvVars(),
- 'phpDatabaseExtension' => $config->getPhpDatabaseExtension(),
- ]),
+ '.github/workflows/ci.yml' => $this->render('ci/github/workflows/ci.yml.tpl', $variables),
+ '.github/ci/Dockerfile' => $this->render('ci/Dockerfile.tpl', $variables),
];
}
@@ -42,6 +45,6 @@ public function supports(Configuration $config): bool
public function getTargetFiles(): array
{
- return ['.github/workflows/ci.yml'];
+ return ['.github/workflows/ci.yml', '.github/ci/Dockerfile'];
}
}
diff --git a/src/Generator/GitLabCiGenerator.php b/src/Generator/GitLabCiGenerator.php
index 7c0fa71..6cc8e61 100644
--- a/src/Generator/GitLabCiGenerator.php
+++ b/src/Generator/GitLabCiGenerator.php
@@ -17,21 +17,24 @@ final class GitLabCiGenerator extends AbstractGenerator
{
public function generate(Configuration $config): array
{
+ $variables = [
+ 'phpVersion' => $config->phpVersion,
+ 'isSymfony' => $config->isSymfonyProject,
+ 'includePhpCsFixer' => $config->includePhpCsFixer,
+ 'includePhpStan' => $config->includePhpStan,
+ 'includeRector' => $config->includeRector,
+ 'hasDatabase' => $config->hasDatabase(),
+ 'databaseType' => $config->databaseType,
+ 'databaseImage' => $config->getDatabaseImage(),
+ 'databasePort' => $config->getDatabasePort(),
+ 'databaseUrl' => $config->getDatabaseUrl(),
+ 'databaseEnvVars' => $config->getDatabaseEnvVars(),
+ 'phpDatabaseExtension' => $config->getPhpDatabaseExtension(),
+ ];
+
return [
- '.gitlab-ci.yml' => $this->render('ci/gitlab-ci.yml.tpl', [
- 'phpVersion' => $config->phpVersion,
- 'isSymfony' => $config->isSymfonyProject,
- 'includePhpCsFixer' => $config->includePhpCsFixer,
- 'includePhpStan' => $config->includePhpStan,
- 'includeRector' => $config->includeRector,
- 'hasDatabase' => $config->hasDatabase(),
- 'databaseType' => $config->databaseType,
- 'databaseImage' => $config->getDatabaseImage(),
- 'databasePort' => $config->getDatabasePort(),
- 'databaseUrl' => $config->getDatabaseUrl(),
- 'databaseEnvVars' => $config->getDatabaseEnvVars(),
- 'phpDatabaseExtension' => $config->getPhpDatabaseExtension(),
- ]),
+ '.gitlab-ci.yml' => $this->render('ci/gitlab-ci.yml.tpl', $variables),
+ '.gitlab/ci/Dockerfile' => $this->render('ci/Dockerfile.tpl', $variables),
];
}
@@ -42,6 +45,6 @@ public function supports(Configuration $config): bool
public function getTargetFiles(): array
{
- return ['.gitlab-ci.yml'];
+ return ['.gitlab-ci.yml', '.gitlab/ci/Dockerfile'];
}
}
diff --git a/templates/ci/Dockerfile.tpl b/templates/ci/Dockerfile.tpl
new file mode 100644
index 0000000..d1b30d3
--- /dev/null
+++ b/templates/ci/Dockerfile.tpl
@@ -0,0 +1,23 @@
+FROM php:= $phpVersion ?>-cli
+
+RUN apt-get update && apt-get install -y \
+ git \
+ unzip \
+ libicu-dev \
+ libzip-dev \
+
+ libpq-dev \
+
+ && docker-php-ext-install \
+ intl \
+ zip \
+
+ pdo \
+ = $phpDatabaseExtension === 'pgsql' ? 'pdo_pgsql' : 'pdo_mysql' ?> \
+
+ && pecl install pcov \
+ && docker-php-ext-enable pcov \
+ && apt-get clean \
+ && rm -rf /var/lib/apt/lists/*
+
+COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
diff --git a/templates/ci/github/workflows/ci.yml.tpl b/templates/ci/github/workflows/ci.yml.tpl
index 49d282a..f48673e 100644
--- a/templates/ci/github/workflows/ci.yml.tpl
+++ b/templates/ci/github/workflows/ci.yml.tpl
@@ -6,19 +6,50 @@ on:
pull_request:
branches: [main, master]
+permissions:
+ contents: read
+ packages: write
+
+env:
+ CI_IMAGE: ghcr.io/${{ github.repository }}/ci:php= $phpVersion ?>
+
+
jobs:
- build:
+ build-image:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- - name: Setup PHP
- uses: shivammathur/setup-php@v2
+ - name: Log in to GHCR
+ uses: docker/login-action@v3
with:
- php-version: '= $phpVersion ?>'
- extensions: intl, zip, pdo, pdo_= $phpDatabaseExtension ?>
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
- coverage: pcov
+ - name: Build and push CI image
+ uses: docker/build-push-action@v6
+ with:
+ context: .
+ file: .github/ci/Dockerfile
+ push: true
+ tags: ${{ env.CI_IMAGE }}
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
+
+ build:
+ needs: [build-image]
+ runs-on: ubuntu-latest
+ container:
+ image: ${{ env.CI_IMAGE }}
+ credentials:
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+ steps:
+ - uses: actions/checkout@v4
- name: Get Composer cache directory
id: composer-cache
@@ -61,17 +92,16 @@ jobs:
lint:
- needs: build
+ needs: [build-image, build]
runs-on: ubuntu-latest
+ container:
+ image: ${{ env.CI_IMAGE }}
+ credentials:
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@v4
- - name: Setup PHP
- uses: shivammathur/setup-php@v2
- with:
- php-version: '= $phpVersion ?>'
- extensions: intl, zip
-
- name: Install dependencies
run: composer install --prefer-dist --no-progress
@@ -89,17 +119,16 @@ jobs:
analyze:
- needs: build
+ needs: [build-image, build]
runs-on: ubuntu-latest
+ container:
+ image: ${{ env.CI_IMAGE }}
+ credentials:
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@v4
- - name: Setup PHP
- uses: shivammathur/setup-php@v2
- with:
- php-version: '= $phpVersion ?>'
- extensions: intl, zip
-
- name: Install dependencies
run: composer install --prefer-dist --no-progress
@@ -120,7 +149,7 @@ jobs:
test:
- needs: build
+ needs: [build-image, build]
runs-on: ubuntu-latest
services:
@@ -132,26 +161,20 @@ jobs:
= $key ?>: = $value ?>
- ports:
- - = $databasePort ?>:= $databasePort ?>
-
options: --health-cmd pg_isready --health-interval=10s --health-timeout=5s --health-retries=3
options: --health-cmd="healthcheck.sh --connect --innodb_initialized" --health-interval=10s --health-timeout=5s --health-retries=3
+ container:
+ image: ${{ env.CI_IMAGE }}
+ credentials:
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@v4
- - name: Setup PHP
- uses: shivammathur/setup-php@v2
- with:
- php-version: '= $phpVersion ?>'
- extensions: intl, zip, pdo, pdo_= $phpDatabaseExtension ?>
-
- coverage: pcov
-
- name: Install dependencies
run: composer install --prefer-dist --no-progress
@@ -164,19 +187,17 @@ jobs:
- name: Create database
run: bin/console doctrine:database:create --if-not-exists --env=test
env:
- DATABASE_URL: = $databaseUrl ?>
-
+ DATABASE_URL: "= str_replace('127.0.0.1', 'database', $databaseUrl) ?>"
- name: Run migrations
run: bin/console doctrine:migrations:migrate --no-interaction --allow-no-migration --env=test
env:
- DATABASE_URL: = $databaseUrl ?>
-
+ DATABASE_URL: "= str_replace('127.0.0.1', 'database', $databaseUrl) ?>"
- name: Run tests
run: bin/phpunit --testdox
env:
- DATABASE_URL: = $databaseUrl ?>
+ DATABASE_URL: "= str_replace('127.0.0.1', 'database', $databaseUrl) ?>"
diff --git a/templates/ci/gitlab-ci.yml.tpl b/templates/ci/gitlab-ci.yml.tpl
index f9dc6d2..137b9a6 100644
--- a/templates/ci/gitlab-ci.yml.tpl
+++ b/templates/ci/gitlab-ci.yml.tpl
@@ -1,4 +1,5 @@
stages:
+ - .pre
- build
- lint
- analyze
@@ -13,9 +14,11 @@ variables:
COMPOSER_ALLOW_SUPERUSER: 1
COMPOSER_NO_INTERACTION: 1
SECRET_DETECTION_ENABLED: 'true'
+ CI_IMAGE: $CI_REGISTRY_IMAGE/ci:php= $phpVersion ?>
-.php-image: &php-image
- image: php:= $phpVersion ?>-cli
+
+.ci-image: &ci-image
+ image: $CI_IMAGE
.composer-cache: &composer-cache
cache:
@@ -33,20 +36,40 @@ variables:
policy: pull
+# ====================
+# Pre Stage — Build CI Image
+# ====================
+
+build:image:
+ stage: .pre
+ image:
+ name: gcr.io/kaniko-project/executor:v1.23.2-debug
+ entrypoint: [""]
+ script:
+ - mkdir -p /kaniko/.docker
+ - echo "{\"auths\":{\"$CI_REGISTRY\":{\"auth\":\"$(printf '%s:%s' "$CI_REGISTRY_USER" "$CI_REGISTRY_PASSWORD" | base64)\"}}}" > /kaniko/.docker/config.json
+ - >-
+ /kaniko/executor
+ --context $CI_PROJECT_DIR
+ --dockerfile $CI_PROJECT_DIR/.gitlab/ci/Dockerfile
+ --destination $CI_IMAGE
+ --cache=true
+ --cache-repo $CI_REGISTRY_IMAGE/ci/cache
+ rules:
+ - changes:
+ - .gitlab/ci/Dockerfile
+ - if: $BUILD_CI_IMAGE == "true"
+ - when: manual
+ allow_failure: true
+
# ====================
# Build Stage
# ====================
build:
- <<: *php-image
+ <<: *ci-image
stage: build
- before_script:
- - apt-get update && apt-get install -y git unzip libicu-dev libzip-dev libpq-dev
-
- - docker-php-ext-install intl zip pdo = $databaseType === 'postgresql' ? 'pdo_pgsql' : 'pdo_mysql' ?>
-
- - pecl install pcov && docker-php-ext-enable pcov
- - curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
+ needs: [build:image]
script:
- composer validate --no-check-publish
- composer install --prefer-dist --no-progress
@@ -80,46 +103,34 @@ build:
# ====================
lint:yaml:
- <<: *php-image
+ <<: *ci-image
<<: *composer-cache
stage: lint
- needs: [build]
- before_script:
- - apt-get update && apt-get install -y libicu-dev libzip-dev
- - docker-php-ext-install intl zip
+ needs: [build:image, build]
script:
- bin/console lint:yaml config --parse-tags
lint:twig:
- <<: *php-image
+ <<: *ci-image
<<: *composer-cache
stage: lint
- needs: [build]
- before_script:
- - apt-get update && apt-get install -y libicu-dev libzip-dev
- - docker-php-ext-install intl zip
+ needs: [build:image, build]
script:
- bin/console lint:twig templates
lint:container:
- <<: *php-image
+ <<: *ci-image
<<: *composer-cache
stage: lint
- needs: [build]
- before_script:
- - apt-get update && apt-get install -y libicu-dev libzip-dev
- - docker-php-ext-install intl zip
+ needs: [build:image, build]
script:
- bin/console lint:container
lint:composer:
- <<: *php-image
+ <<: *ci-image
<<: *composer-cache
stage: lint
- needs: [build]
- before_script:
- - apt-get update && apt-get install -y git unzip
- - curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
+ needs: [build:image, build]
script:
- composer validate --no-check-publish
@@ -130,23 +141,20 @@ lint:composer:
php-cs-fixer:
- <<: *php-image
+ <<: *ci-image
<<: *composer-cache
stage: analyze
- needs: [build]
+ needs: [build:image, build]
script:
- tools/php-cs-fixer/vendor/bin/php-cs-fixer fix --dry-run --diff
phpstan:
- <<: *php-image
+ <<: *ci-image
<<: *composer-cache
stage: analyze
- needs: [build]
- before_script:
- - apt-get update && apt-get install -y libicu-dev libzip-dev
- - docker-php-ext-install intl zip
+ needs: [build:image, build]
script:
- tools/phpstan/vendor/bin/phpstan analyse
@@ -156,9 +164,9 @@ phpstan:
# ====================
phpunit:
- <<: *php-image
+ <<: *ci-image
stage: test
- needs: [build]
+ needs: [build:image, build]
services:
- name: = $databaseImage ?>
@@ -172,14 +180,7 @@ phpunit:
variables:
APP_ENV: test
DATABASE_URL: "= str_replace('127.0.0.1', 'database', $databaseUrl) ?>"
-
before_script:
- - apt-get update && apt-get install -y git unzip libicu-dev libzip-dev libpq-dev
-
- - docker-php-ext-install intl zip pdo = $databaseType === 'postgresql' ? 'pdo_pgsql' : 'pdo_mysql' ?>
-
- - pecl install pcov && docker-php-ext-enable pcov
-
# Wait for database to be ready
- |
for i in $(seq 1 30); do
diff --git a/tests/Command/CiAddCommandTest.php b/tests/Command/CiAddCommandTest.php
new file mode 100644
index 0000000..6f91665
--- /dev/null
+++ b/tests/Command/CiAddCommandTest.php
@@ -0,0 +1,141 @@
+
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Enabel\CodingStandard\Tests\Command;
+
+use Enabel\CodingStandard\Command\CiAddCommand;
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Tester\CommandTester;
+use Symfony\Component\Filesystem\Filesystem;
+
+final class CiAddCommandTest extends TestCase
+{
+ private string $tempDir;
+ private Filesystem $filesystem;
+
+ protected function setUp(): void
+ {
+ $this->filesystem = new Filesystem();
+ $this->tempDir = sys_get_temp_dir() . '/ci-add-command-test-' . uniqid();
+ mkdir($this->tempDir);
+
+ $this->writeComposerJson([
+ 'name' => 'enabel/test-project',
+ 'require' => ['php' => '>=8.4'],
+ ]);
+
+ file_put_contents($this->tempDir . '/.php-cs-fixer.dist.php', 'tempDir . '/phpstan.neon', 'parameters:');
+ }
+
+ protected function tearDown(): void
+ {
+ $this->filesystem->remove($this->tempDir);
+ }
+
+ public function testMissingProviderInNonInteractiveModeReturnFailure(): void
+ {
+ $tester = $this->createCommandTester();
+
+ $tester->execute(['--output-dir' => $this->tempDir], ['interactive' => false]);
+
+ self::assertSame(Command::FAILURE, $tester->getStatusCode());
+ self::assertStringContainsString('--provider option is required', $tester->getDisplay());
+ }
+
+ public function testProviderAlreadyExistsReturnFailure(): void
+ {
+ file_put_contents($this->tempDir . '/.gitlab-ci.yml', 'stages: [test]');
+
+ $tester = $this->createCommandTester();
+
+ $tester->execute(['--provider' => 'gitlab', '--output-dir' => $this->tempDir], ['interactive' => false]);
+
+ self::assertSame(Command::FAILURE, $tester->getStatusCode());
+ self::assertStringContainsString('already exists', $tester->getDisplay());
+ }
+
+ public function testForceOverwritesExistingProvider(): void
+ {
+ file_put_contents($this->tempDir . '/.gitlab-ci.yml', 'stages: [test]');
+
+ $tester = $this->createCommandTester();
+
+ $tester->execute(
+ ['--provider' => 'gitlab', '--force' => true, '--output-dir' => $this->tempDir],
+ ['interactive' => false],
+ );
+
+ self::assertSame(Command::SUCCESS, $tester->getStatusCode());
+ self::assertFileExists($this->tempDir . '/.gitlab-ci.yml');
+ }
+
+ public function testGeneratesGitHubFiles(): void
+ {
+ $tester = $this->createCommandTester();
+
+ $tester->execute(['--provider' => 'github', '--output-dir' => $this->tempDir], ['interactive' => false]);
+
+ self::assertSame(Command::SUCCESS, $tester->getStatusCode());
+ self::assertFileExists($this->tempDir . '/.github/workflows/ci.yml');
+ self::assertFileExists($this->tempDir . '/.github/ci/Dockerfile');
+
+ $ci = (string) file_get_contents($this->tempDir . '/.github/workflows/ci.yml');
+ self::assertStringContainsString('name:', $ci);
+
+ $dockerfile = (string) file_get_contents($this->tempDir . '/.github/ci/Dockerfile');
+ self::assertStringContainsString('php:8.4', $dockerfile);
+ }
+
+ public function testGeneratesGitLabFiles(): void
+ {
+ $tester = $this->createCommandTester();
+
+ $tester->execute(['--provider' => 'gitlab', '--output-dir' => $this->tempDir], ['interactive' => false]);
+
+ self::assertSame(Command::SUCCESS, $tester->getStatusCode());
+ self::assertFileExists($this->tempDir . '/.gitlab-ci.yml');
+ self::assertFileExists($this->tempDir . '/.gitlab/ci/Dockerfile');
+
+ $ci = (string) file_get_contents($this->tempDir . '/.gitlab-ci.yml');
+ self::assertStringContainsString('stages:', $ci);
+
+ $dockerfile = (string) file_get_contents($this->tempDir . '/.gitlab/ci/Dockerfile');
+ self::assertStringContainsString('php:8.4', $dockerfile);
+ }
+
+ public function testInvalidProviderReturnFailure(): void
+ {
+ $tester = $this->createCommandTester();
+
+ $tester->execute(['--provider' => 'jenkins', '--output-dir' => $this->tempDir], ['interactive' => false]);
+
+ self::assertSame(Command::FAILURE, $tester->getStatusCode());
+ self::assertStringContainsString('Invalid provider', $tester->getDisplay());
+ }
+
+ private function createCommandTester(): CommandTester
+ {
+ return new CommandTester(new CiAddCommand());
+ }
+
+ /**
+ * @param array $data
+ */
+ private function writeComposerJson(array $data): void
+ {
+ file_put_contents(
+ $this->tempDir . '/composer.json',
+ json_encode($data, \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES),
+ );
+ }
+}
diff --git a/tests/Command/CiUpdateCommandTest.php b/tests/Command/CiUpdateCommandTest.php
new file mode 100644
index 0000000..9bad909
--- /dev/null
+++ b/tests/Command/CiUpdateCommandTest.php
@@ -0,0 +1,143 @@
+
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Enabel\CodingStandard\Tests\Command;
+
+use Enabel\CodingStandard\Command\CiUpdateCommand;
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Tester\CommandTester;
+use Symfony\Component\Filesystem\Filesystem;
+
+final class CiUpdateCommandTest extends TestCase
+{
+ private string $tempDir;
+ private Filesystem $filesystem;
+
+ protected function setUp(): void
+ {
+ $this->filesystem = new Filesystem();
+ $this->tempDir = sys_get_temp_dir() . '/ci-update-command-test-' . uniqid();
+ mkdir($this->tempDir);
+
+ // Simulate a PHP project with composer.json
+ $this->writeComposerJson([
+ 'name' => 'enabel/test-project',
+ 'require' => [
+ 'php' => '>=8.4',
+ 'symfony/framework-bundle' => '^7.4',
+ ],
+ ]);
+
+ // Simulate tool config files so they are detected
+ file_put_contents($this->tempDir . '/.php-cs-fixer.dist.php', 'tempDir . '/phpstan.neon', 'parameters:');
+ }
+
+ protected function tearDown(): void
+ {
+ $this->filesystem->remove($this->tempDir);
+ }
+
+ public function testFailsWhenNoCiDetected(): void
+ {
+ $tester = $this->executeCommand();
+
+ self::assertSame(Command::FAILURE, $tester->getStatusCode());
+ self::assertStringContainsString('No existing CI configuration', $tester->getDisplay());
+ }
+
+ public function testRegeneratesGitLabWhenGitLabCiExists(): void
+ {
+ file_put_contents($this->tempDir . '/.gitlab-ci.yml', 'stages: [test]');
+
+ $tester = $this->executeCommand();
+
+ self::assertSame(Command::SUCCESS, $tester->getStatusCode());
+ self::assertFileExists($this->tempDir . '/.gitlab-ci.yml');
+ self::assertFileExists($this->tempDir . '/.gitlab/ci/Dockerfile');
+ }
+
+ public function testRegeneratesBothWhenGitLabAndGitHubExist(): void
+ {
+ file_put_contents($this->tempDir . '/.gitlab-ci.yml', 'stages: [test]');
+ $this->filesystem->mkdir($this->tempDir . '/.github/workflows');
+ file_put_contents($this->tempDir . '/.github/workflows/ci.yml', 'name: CI');
+
+ $tester = $this->executeCommand();
+
+ self::assertSame(Command::SUCCESS, $tester->getStatusCode());
+
+ // GitLab files
+ self::assertFileExists($this->tempDir . '/.gitlab-ci.yml');
+ self::assertFileExists($this->tempDir . '/.gitlab/ci/Dockerfile');
+
+ // GitHub files
+ self::assertFileExists($this->tempDir . '/.github/workflows/ci.yml');
+ self::assertFileExists($this->tempDir . '/.github/ci/Dockerfile');
+ }
+
+ public function testOverridePhpVersionChangesDockerfile(): void
+ {
+ file_put_contents($this->tempDir . '/.gitlab-ci.yml', 'stages: [test]');
+
+ $tester = $this->executeCommand(['--php-version' => '8.5']);
+
+ self::assertSame(Command::SUCCESS, $tester->getStatusCode());
+
+ $dockerfile = (string) file_get_contents($this->tempDir . '/.gitlab/ci/Dockerfile');
+ self::assertStringContainsString('php:8.5-cli', $dockerfile);
+ }
+
+ public function testOverrideDatabaseAddsDbToCi(): void
+ {
+ file_put_contents($this->tempDir . '/.gitlab-ci.yml', 'stages: [test]');
+
+ $tester = $this->executeCommand([
+ '--database' => 'mariadb',
+ '--database-version' => '11.4',
+ ]);
+
+ self::assertSame(Command::SUCCESS, $tester->getStatusCode());
+
+ $dockerfile = (string) file_get_contents($this->tempDir . '/.gitlab/ci/Dockerfile');
+ self::assertStringContainsString('pdo_mysql', $dockerfile);
+
+ $ciYml = (string) file_get_contents($this->tempDir . '/.gitlab-ci.yml');
+ self::assertStringContainsString('mariadb', $ciYml);
+ }
+
+ /**
+ * @param array $input
+ */
+ private function executeCommand(array $input = []): CommandTester
+ {
+ $command = new CiUpdateCommand();
+ $tester = new CommandTester($command);
+ $tester->execute(
+ array_merge(['--output-dir' => $this->tempDir], $input),
+ ['interactive' => false],
+ );
+
+ return $tester;
+ }
+
+ /**
+ * @param array $data
+ */
+ private function writeComposerJson(array $data): void
+ {
+ file_put_contents(
+ $this->tempDir . '/composer.json',
+ json_encode($data, \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES),
+ );
+ }
+}
diff --git a/tests/Detector/ProjectDetectorTest.php b/tests/Detector/ProjectDetectorTest.php
index 2fe08fd..86736bf 100644
--- a/tests/Detector/ProjectDetectorTest.php
+++ b/tests/Detector/ProjectDetectorTest.php
@@ -13,26 +13,23 @@
use Enabel\CodingStandard\Detector\ProjectDetector;
use PHPUnit\Framework\TestCase;
+use Symfony\Component\Filesystem\Filesystem;
final class ProjectDetectorTest extends TestCase
{
private string $tempDir;
+ private Filesystem $filesystem;
protected function setUp(): void
{
+ $this->filesystem = new Filesystem();
$this->tempDir = sys_get_temp_dir() . '/project-detector-test-' . uniqid();
mkdir($this->tempDir);
}
protected function tearDown(): void
{
- foreach (['composer.json', 'composer.lock'] as $file) {
- $path = $this->tempDir . '/' . $file;
- if (file_exists($path)) {
- unlink($path);
- }
- }
- rmdir($this->tempDir);
+ $this->filesystem->remove($this->tempDir);
}
public function testGetProjectNameFromComposerJson(): void
@@ -149,6 +146,165 @@ public function testGetSymfonyVersionReturnsNullWhenNotSymfony(): void
self::assertNull($detector->getSymfonyVersion());
}
+ // --- CI Provider detection ---
+
+ public function testDetectCiProvidersReturnsEmptyWhenNoCiFiles(): void
+ {
+ $detector = new ProjectDetector($this->tempDir);
+
+ self::assertSame([], $detector->detectCiProviders());
+ }
+
+ public function testDetectCiProvidersDetectsGitlab(): void
+ {
+ file_put_contents($this->tempDir . '/.gitlab-ci.yml', 'stages: [test]');
+
+ $detector = new ProjectDetector($this->tempDir);
+
+ self::assertSame(['gitlab'], $detector->detectCiProviders());
+ }
+
+ public function testDetectCiProvidersDetectsGithub(): void
+ {
+ $this->filesystem->mkdir($this->tempDir . '/.github/workflows');
+ file_put_contents($this->tempDir . '/.github/workflows/ci.yml', 'name: CI');
+
+ $detector = new ProjectDetector($this->tempDir);
+
+ self::assertSame(['github'], $detector->detectCiProviders());
+ }
+
+ public function testDetectCiProvidersDetectsAzure(): void
+ {
+ file_put_contents($this->tempDir . '/azure-pipelines.yml', 'trigger: [main]');
+
+ $detector = new ProjectDetector($this->tempDir);
+
+ self::assertSame(['azure'], $detector->detectCiProviders());
+ }
+
+ public function testDetectCiProvidersDetectsMultiple(): void
+ {
+ file_put_contents($this->tempDir . '/.gitlab-ci.yml', 'stages: [test]');
+ $this->filesystem->mkdir($this->tempDir . '/.github/workflows');
+ file_put_contents($this->tempDir . '/.github/workflows/ci.yml', 'name: CI');
+
+ $detector = new ProjectDetector($this->tempDir);
+
+ self::assertSame(['gitlab', 'github'], $detector->detectCiProviders());
+ }
+
+ // --- Database detection ---
+
+ public function testDetectDatabaseReturnsNullWithoutComposeYaml(): void
+ {
+ $detector = new ProjectDetector($this->tempDir);
+
+ self::assertNull($detector->detectDatabase());
+ }
+
+ public function testDetectDatabaseDetectsMariadb(): void
+ {
+ file_put_contents($this->tempDir . '/compose.yaml', "services:\n database:\n image: mariadb:11.4\n");
+
+ $detector = new ProjectDetector($this->tempDir);
+
+ self::assertSame(['type' => 'mariadb', 'version' => '11.4'], $detector->detectDatabase());
+ }
+
+ public function testDetectDatabaseDetectsMysql(): void
+ {
+ file_put_contents($this->tempDir . '/compose.yaml', "services:\n database:\n image: mysql:8.4\n");
+
+ $detector = new ProjectDetector($this->tempDir);
+
+ self::assertSame(['type' => 'mysql', 'version' => '8.4'], $detector->detectDatabase());
+ }
+
+ public function testDetectDatabaseDetectsPostgresql(): void
+ {
+ file_put_contents($this->tempDir . '/compose.yaml', "services:\n database:\n image: postgres:17\n");
+
+ $detector = new ProjectDetector($this->tempDir);
+
+ self::assertSame(['type' => 'postgresql', 'version' => '17'], $detector->detectDatabase());
+ }
+
+ public function testDetectDatabaseReturnsNullWhenNoDbImage(): void
+ {
+ file_put_contents($this->tempDir . '/compose.yaml', "services:\n app:\n image: php:8.4\n");
+
+ $detector = new ProjectDetector($this->tempDir);
+
+ self::assertNull($detector->detectDatabase());
+ }
+
+ // --- Tool detection ---
+
+ public function testHasPhpCsFixerReturnsTrueWhenFileExists(): void
+ {
+ file_put_contents($this->tempDir . '/.php-cs-fixer.dist.php', 'tempDir);
+
+ self::assertTrue($detector->hasPhpCsFixer());
+ }
+
+ public function testHasPhpCsFixerReturnsFalseWhenFileAbsent(): void
+ {
+ $detector = new ProjectDetector($this->tempDir);
+
+ self::assertFalse($detector->hasPhpCsFixer());
+ }
+
+ public function testHasPhpStanReturnsTrueWhenFileExists(): void
+ {
+ file_put_contents($this->tempDir . '/phpstan.neon', 'parameters:');
+
+ $detector = new ProjectDetector($this->tempDir);
+
+ self::assertTrue($detector->hasPhpStan());
+ }
+
+ public function testHasPhpStanReturnsFalseWhenFileAbsent(): void
+ {
+ $detector = new ProjectDetector($this->tempDir);
+
+ self::assertFalse($detector->hasPhpStan());
+ }
+
+ public function testHasRectorReturnsTrueWhenFileExists(): void
+ {
+ file_put_contents($this->tempDir . '/rector.php', 'tempDir);
+
+ self::assertTrue($detector->hasRector());
+ }
+
+ public function testHasRectorReturnsFalseWhenFileAbsent(): void
+ {
+ $detector = new ProjectDetector($this->tempDir);
+
+ self::assertFalse($detector->hasRector());
+ }
+
+ public function testHasPhpUnitReturnsTrueWhenFileExists(): void
+ {
+ file_put_contents($this->tempDir . '/phpunit.dist.xml', '');
+
+ $detector = new ProjectDetector($this->tempDir);
+
+ self::assertTrue($detector->hasPhpUnit());
+ }
+
+ public function testHasPhpUnitReturnsFalseWhenFileAbsent(): void
+ {
+ $detector = new ProjectDetector($this->tempDir);
+
+ self::assertFalse($detector->hasPhpUnit());
+ }
+
/**
* @param array $data
*/
diff --git a/tests/Generator/GitHubActionsGeneratorTest.php b/tests/Generator/GitHubActionsGeneratorTest.php
new file mode 100644
index 0000000..50c1172
--- /dev/null
+++ b/tests/Generator/GitHubActionsGeneratorTest.php
@@ -0,0 +1,226 @@
+
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Enabel\CodingStandard\Tests\Generator;
+
+use Enabel\CodingStandard\Config\Configuration;
+use Enabel\CodingStandard\Config\ConflictResolution;
+use Enabel\CodingStandard\Generator\GitHubActionsGenerator;
+use Enabel\CodingStandard\Template\TemplateRenderer;
+use PHPUnit\Framework\TestCase;
+
+final class GitHubActionsGeneratorTest extends TestCase
+{
+ private GitHubActionsGenerator $generator;
+
+ protected function setUp(): void
+ {
+ $templatesPath = dirname(__DIR__, 2) . '/templates';
+ $renderer = new TemplateRenderer($templatesPath);
+ $this->generator = new GitHubActionsGenerator($renderer);
+ }
+
+ public function testSupportsReturnsTrueForGithub(): void
+ {
+ $config = $this->createConfiguration(ciProvider: 'github');
+
+ self::assertTrue($this->generator->supports($config));
+ }
+
+ public function testSupportsReturnsFalseForGitlab(): void
+ {
+ $config = $this->createConfiguration(ciProvider: 'gitlab');
+
+ self::assertFalse($this->generator->supports($config));
+ }
+
+ public function testSupportsReturnsFalseForNone(): void
+ {
+ $config = $this->createConfiguration(ciProvider: 'none');
+
+ self::assertFalse($this->generator->supports($config));
+ }
+
+ public function testGenerateReturnsTwoFiles(): void
+ {
+ $config = $this->createConfiguration(ciProvider: 'github');
+
+ $files = $this->generator->generate($config);
+
+ self::assertArrayHasKey('.github/workflows/ci.yml', $files);
+ self::assertArrayHasKey('.github/ci/Dockerfile', $files);
+ }
+
+ public function testDockerfileIsIdenticalToGitlab(): void
+ {
+ $config = $this->createConfiguration(ciProvider: 'github');
+
+ $files = $this->generator->generate($config);
+
+ self::assertStringContainsString('php:8.4-cli', $files['.github/ci/Dockerfile']);
+ self::assertStringContainsString('pcov', $files['.github/ci/Dockerfile']);
+ self::assertStringContainsString('composer', $files['.github/ci/Dockerfile']);
+ }
+
+ public function testCiContainsBuildImageJob(): void
+ {
+ $config = $this->createConfiguration(ciProvider: 'github');
+
+ $files = $this->generator->generate($config);
+
+ $ci = $files['.github/workflows/ci.yml'];
+ self::assertStringContainsString('build-image:', $ci);
+ self::assertStringContainsString('docker/build-push-action@v6', $ci);
+ }
+
+ public function testCiContainsPackagesWritePermission(): void
+ {
+ $config = $this->createConfiguration(ciProvider: 'github');
+
+ $files = $this->generator->generate($config);
+
+ self::assertStringContainsString('packages: write', $files['.github/workflows/ci.yml']);
+ }
+
+ public function testCiDoesNotContainSetupPhp(): void
+ {
+ $config = $this->createConfiguration(ciProvider: 'github');
+
+ $files = $this->generator->generate($config);
+
+ self::assertStringNotContainsString('shivammathur/setup-php', $files['.github/workflows/ci.yml']);
+ }
+
+ public function testCiJobsUseContainer(): void
+ {
+ $config = $this->createConfiguration(ciProvider: 'github');
+
+ $files = $this->generator->generate($config);
+
+ $ci = $files['.github/workflows/ci.yml'];
+ self::assertStringContainsString('container:', $ci);
+ self::assertStringContainsString('image: ${{ env.CI_IMAGE }}', $ci);
+ }
+
+ public function testCiWithDatabaseUsesDatabaseHostname(): void
+ {
+ $config = $this->createConfiguration(ciProvider: 'github', databaseType: 'mariadb', databaseVersion: '11.4');
+
+ $files = $this->generator->generate($config);
+
+ $ci = $files['.github/workflows/ci.yml'];
+ self::assertStringContainsString('database', $ci);
+ self::assertStringContainsString('DATABASE_URL:', $ci);
+ }
+
+ public function testCiWithDatabaseDoesNotExposePorts(): void
+ {
+ $config = $this->createConfiguration(ciProvider: 'github', databaseType: 'mariadb', databaseVersion: '11.4');
+
+ $files = $this->generator->generate($config);
+
+ self::assertStringNotContainsString('ports:', $files['.github/workflows/ci.yml']);
+ }
+
+ public function testCiWithPostgresqlDatabase(): void
+ {
+ $config = $this->createConfiguration(ciProvider: 'github', databaseType: 'postgresql', databaseVersion: '17');
+
+ $files = $this->generator->generate($config);
+
+ $ci = $files['.github/workflows/ci.yml'];
+ self::assertStringContainsString('postgres:17', $ci);
+ self::assertStringContainsString('pg_isready', $ci);
+
+ $dockerfile = $files['.github/ci/Dockerfile'];
+ self::assertStringContainsString('pdo_pgsql', $dockerfile);
+ self::assertStringContainsString('libpq-dev', $dockerfile);
+ }
+
+ public function testCiWithSymfonyHasLintJob(): void
+ {
+ $config = $this->createConfiguration(ciProvider: 'github', isSymfony: true);
+
+ $files = $this->generator->generate($config);
+
+ $ci = $files['.github/workflows/ci.yml'];
+ self::assertStringContainsString('lint:', $ci);
+ self::assertStringContainsString('lint:yaml', $ci);
+ self::assertStringContainsString('lint:twig', $ci);
+ }
+
+ public function testCiWithoutSymfonyHasNoLintJob(): void
+ {
+ $config = $this->createConfiguration(ciProvider: 'github', isSymfony: false);
+
+ $files = $this->generator->generate($config);
+
+ self::assertStringNotContainsString('lint:', $files['.github/workflows/ci.yml']);
+ }
+
+ public function testCiContainsGhcrImage(): void
+ {
+ $config = $this->createConfiguration(ciProvider: 'github', phpVersion: '8.4');
+
+ $files = $this->generator->generate($config);
+
+ self::assertStringContainsString('ghcr.io/${{ github.repository }}/ci:php8.4', $files['.github/workflows/ci.yml']);
+ }
+
+ public function testCiJobsNeedBuildImage(): void
+ {
+ $config = $this->createConfiguration(ciProvider: 'github');
+
+ $files = $this->generator->generate($config);
+
+ $ci = $files['.github/workflows/ci.yml'];
+ self::assertStringContainsString('needs: [build-image]', $ci);
+ self::assertStringContainsString('needs: [build-image, build]', $ci);
+ }
+
+ public function testGetTargetFilesReturnsBothFiles(): void
+ {
+ $expected = ['.github/workflows/ci.yml', '.github/ci/Dockerfile'];
+
+ self::assertSame($expected, $this->generator->getTargetFiles());
+ }
+
+ private function createConfiguration(
+ string $ciProvider = 'github',
+ string $phpVersion = '8.4',
+ bool $isSymfony = true,
+ bool $includePhpCsFixer = true,
+ bool $includePhpStan = true,
+ ?string $databaseType = null,
+ ?string $databaseVersion = null,
+ ): Configuration {
+ return new Configuration(
+ projectName: 'test-project',
+ phpVersion: $phpVersion,
+ phpstanLevel: 9,
+ isSymfonyProject: $isSymfony,
+ symfonyVersion: '8.0',
+ ciProvider: $ciProvider,
+ devEnvironment: 'local',
+ includeMakefile: false,
+ includePhpCsFixer: $includePhpCsFixer,
+ includePhpStan: $includePhpStan,
+ includeRector: false,
+ includePhpUnit: true,
+ srcPath: 'src',
+ testsPath: 'tests',
+ outputDir: '.',
+ conflictResolution: ConflictResolution::ASK,
+ databaseType: $databaseType,
+ databaseVersion: $databaseVersion,
+ );
+ }
+}
diff --git a/tests/Generator/GitLabCiGeneratorTest.php b/tests/Generator/GitLabCiGeneratorTest.php
new file mode 100644
index 0000000..b1c0caa
--- /dev/null
+++ b/tests/Generator/GitLabCiGeneratorTest.php
@@ -0,0 +1,241 @@
+
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Enabel\CodingStandard\Tests\Generator;
+
+use Enabel\CodingStandard\Config\Configuration;
+use Enabel\CodingStandard\Config\ConflictResolution;
+use Enabel\CodingStandard\Generator\GitLabCiGenerator;
+use Enabel\CodingStandard\Template\TemplateRenderer;
+use PHPUnit\Framework\TestCase;
+
+final class GitLabCiGeneratorTest extends TestCase
+{
+ private GitLabCiGenerator $generator;
+
+ protected function setUp(): void
+ {
+ $templatesPath = dirname(__DIR__, 2) . '/templates';
+ $renderer = new TemplateRenderer($templatesPath);
+ $this->generator = new GitLabCiGenerator($renderer);
+ }
+
+ public function testSupportsReturnsTrueForGitlab(): void
+ {
+ $config = $this->createConfiguration(ciProvider: 'gitlab');
+
+ self::assertTrue($this->generator->supports($config));
+ }
+
+ public function testSupportsReturnsFalseForGithub(): void
+ {
+ $config = $this->createConfiguration(ciProvider: 'github');
+
+ self::assertFalse($this->generator->supports($config));
+ }
+
+ public function testSupportsReturnsFalseForNone(): void
+ {
+ $config = $this->createConfiguration(ciProvider: 'none');
+
+ self::assertFalse($this->generator->supports($config));
+ }
+
+ public function testGenerateReturnsTwoFiles(): void
+ {
+ $config = $this->createConfiguration(ciProvider: 'gitlab');
+
+ $files = $this->generator->generate($config);
+
+ self::assertArrayHasKey('.gitlab-ci.yml', $files);
+ self::assertArrayHasKey('.gitlab/ci/Dockerfile', $files);
+ }
+
+ public function testDockerfileContainsPhpVersion(): void
+ {
+ $config = $this->createConfiguration(ciProvider: 'gitlab', phpVersion: '8.4');
+
+ $files = $this->generator->generate($config);
+
+ self::assertStringContainsString('php:8.4-cli', $files['.gitlab/ci/Dockerfile']);
+ }
+
+ public function testDockerfileContainsPcov(): void
+ {
+ $config = $this->createConfiguration(ciProvider: 'gitlab');
+
+ $files = $this->generator->generate($config);
+
+ self::assertStringContainsString('pcov', $files['.gitlab/ci/Dockerfile']);
+ }
+
+ public function testDockerfileContainsComposer(): void
+ {
+ $config = $this->createConfiguration(ciProvider: 'gitlab');
+
+ $files = $this->generator->generate($config);
+
+ self::assertStringContainsString('composer', $files['.gitlab/ci/Dockerfile']);
+ }
+
+ public function testDockerfileContainsPdoMysqlForMariadb(): void
+ {
+ $config = $this->createConfiguration(ciProvider: 'gitlab', databaseType: 'mariadb', databaseVersion: '11.4');
+
+ $files = $this->generator->generate($config);
+
+ self::assertStringContainsString('pdo_mysql', $files['.gitlab/ci/Dockerfile']);
+ self::assertStringNotContainsString('pdo_pgsql', $files['.gitlab/ci/Dockerfile']);
+ }
+
+ public function testDockerfileContainsPdoPgsqlForPostgresql(): void
+ {
+ $config = $this->createConfiguration(ciProvider: 'gitlab', databaseType: 'postgresql', databaseVersion: '17');
+
+ $files = $this->generator->generate($config);
+
+ self::assertStringContainsString('pdo_pgsql', $files['.gitlab/ci/Dockerfile']);
+ self::assertStringContainsString('libpq-dev', $files['.gitlab/ci/Dockerfile']);
+ self::assertStringNotContainsString('pdo_mysql', $files['.gitlab/ci/Dockerfile']);
+ }
+
+ public function testCiContainsBuildImageJob(): void
+ {
+ $config = $this->createConfiguration(ciProvider: 'gitlab');
+
+ $files = $this->generator->generate($config);
+
+ $ci = $files['.gitlab-ci.yml'];
+ self::assertStringContainsString('build:image:', $ci);
+ self::assertStringContainsString('kaniko', $ci);
+ self::assertStringContainsString('$CI_IMAGE', $ci);
+ }
+
+ public function testCiDoesNotContainAptGet(): void
+ {
+ $config = $this->createConfiguration(ciProvider: 'gitlab');
+
+ $files = $this->generator->generate($config);
+
+ self::assertStringNotContainsString('apt-get', $files['.gitlab-ci.yml']);
+ }
+
+ public function testCiDoesNotContainDockerPhpExtInstall(): void
+ {
+ $config = $this->createConfiguration(ciProvider: 'gitlab');
+
+ $files = $this->generator->generate($config);
+
+ self::assertStringNotContainsString('docker-php-ext-install', $files['.gitlab-ci.yml']);
+ }
+
+ public function testCiWithDatabaseKeepsDbWaitInPhpunit(): void
+ {
+ $config = $this->createConfiguration(ciProvider: 'gitlab', databaseType: 'mariadb', databaseVersion: '11.4');
+
+ $files = $this->generator->generate($config);
+
+ $ci = $files['.gitlab-ci.yml'];
+ self::assertStringContainsString('Waiting for database', $ci);
+ self::assertStringContainsString('PDO', $ci);
+ }
+
+ public function testCiWithoutDatabaseHasNoDbWait(): void
+ {
+ $config = $this->createConfiguration(ciProvider: 'gitlab');
+
+ $files = $this->generator->generate($config);
+
+ self::assertStringNotContainsString('Waiting for database', $files['.gitlab-ci.yml']);
+ }
+
+ public function testCiWithSymfonyHasLintJobs(): void
+ {
+ $config = $this->createConfiguration(ciProvider: 'gitlab', isSymfony: true);
+
+ $files = $this->generator->generate($config);
+
+ $ci = $files['.gitlab-ci.yml'];
+ self::assertStringContainsString('lint:yaml:', $ci);
+ self::assertStringContainsString('lint:twig:', $ci);
+ self::assertStringContainsString('lint:container:', $ci);
+ self::assertStringContainsString('lint:composer:', $ci);
+ }
+
+ public function testCiWithoutSymfonyHasNoLintJobs(): void
+ {
+ $config = $this->createConfiguration(ciProvider: 'gitlab', isSymfony: false);
+
+ $files = $this->generator->generate($config);
+
+ $ci = $files['.gitlab-ci.yml'];
+ self::assertStringNotContainsString('lint:yaml:', $ci);
+ self::assertStringNotContainsString('lint:twig:', $ci);
+ }
+
+ public function testCiUsesPreStage(): void
+ {
+ $config = $this->createConfiguration(ciProvider: 'gitlab');
+
+ $files = $this->generator->generate($config);
+
+ self::assertStringContainsString('.pre', $files['.gitlab-ci.yml']);
+ }
+
+ public function testCiUsesImageVariable(): void
+ {
+ $config = $this->createConfiguration(ciProvider: 'gitlab', phpVersion: '8.4');
+
+ $files = $this->generator->generate($config);
+
+ $ci = $files['.gitlab-ci.yml'];
+ self::assertStringContainsString('CI_IMAGE: $CI_REGISTRY_IMAGE/ci:php8.4', $ci);
+ self::assertStringContainsString('.ci-image:', $ci);
+ }
+
+ public function testGetTargetFilesReturnsBothFiles(): void
+ {
+ $expected = ['.gitlab-ci.yml', '.gitlab/ci/Dockerfile'];
+
+ self::assertSame($expected, $this->generator->getTargetFiles());
+ }
+
+ private function createConfiguration(
+ string $ciProvider = 'gitlab',
+ string $phpVersion = '8.4',
+ bool $isSymfony = true,
+ bool $includePhpCsFixer = true,
+ bool $includePhpStan = true,
+ ?string $databaseType = null,
+ ?string $databaseVersion = null,
+ ): Configuration {
+ return new Configuration(
+ projectName: 'test-project',
+ phpVersion: $phpVersion,
+ phpstanLevel: 9,
+ isSymfonyProject: $isSymfony,
+ symfonyVersion: '8.0',
+ ciProvider: $ciProvider,
+ devEnvironment: 'local',
+ includeMakefile: false,
+ includePhpCsFixer: $includePhpCsFixer,
+ includePhpStan: $includePhpStan,
+ includeRector: false,
+ includePhpUnit: true,
+ srcPath: 'src',
+ testsPath: 'tests',
+ outputDir: '.',
+ conflictResolution: ConflictResolution::ASK,
+ databaseType: $databaseType,
+ databaseVersion: $databaseVersion,
+ );
+ }
+}