From 1641d697e9c970fce67a2755b1709a275cad1b64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erik=20Fr=C3=A8rejean?= Date: Fri, 3 Jul 2026 10:59:20 +0200 Subject: [PATCH 1/6] Add Dockerised SS6 test harness, QA configs, Taskfile --- .docker/Dockerfile | 35 ++++++++++ .docker/app/_config/config.yml | 3 + .docker/app/composer.json | 59 ++++++++++++++++ .docker/app/phpstan-php85.neon.dist | 9 +++ .docker/app/phpstan.neon.dist | 13 ++++ .docker/app/phpunit.xml.dist | 21 ++++++ .docker/app/rector.php | 34 ++++++++++ .docker/compose.ci.yml | 16 +++++ .docker/compose.yml | 49 +++++++++++++ .docker/entrypoint.sh | 13 ++++ .gitignore | 7 ++ Taskfile.yml | 102 ++++++++++++++++++++++++++++ composer.json | 18 ++++- coverage/.gitignore | 2 + tests/Integration/.gitkeep | 0 tests/Unit/.gitkeep | 0 16 files changed, 380 insertions(+), 1 deletion(-) create mode 100644 .docker/Dockerfile create mode 100644 .docker/app/_config/config.yml create mode 100644 .docker/app/composer.json create mode 100644 .docker/app/phpstan-php85.neon.dist create mode 100644 .docker/app/phpstan.neon.dist create mode 100644 .docker/app/phpunit.xml.dist create mode 100644 .docker/app/rector.php create mode 100644 .docker/compose.ci.yml create mode 100644 .docker/compose.yml create mode 100755 .docker/entrypoint.sh create mode 100644 .gitignore create mode 100644 Taskfile.yml create mode 100644 coverage/.gitignore create mode 100644 tests/Integration/.gitkeep create mode 100644 tests/Unit/.gitkeep diff --git a/.docker/Dockerfile b/.docker/Dockerfile new file mode 100644 index 0000000..81efbf6 --- /dev/null +++ b/.docker/Dockerfile @@ -0,0 +1,35 @@ +ARG PHP_VERSION=8.3 +FROM php:${PHP_VERSION}-cli + +# mlocati's installer builds the needed extensions across all target PHP versions. +COPY --from=mlocati/php-extension-installer /usr/bin/install-php-extensions /usr/local/bin/ +RUN install-php-extensions intl gd mysqli pdo_mysql zip bcmath exif pcov + +# Measure coverage against the mounted module source only. +RUN echo 'pcov.directory=/module/src' > /usr/local/etc/php/conf.d/99-pcov.ini + +COPY --from=composer:latest /usr/bin/composer /usr/bin/composer + +WORKDIR /app + +COPY app/composer.json /app/composer.json + +# SilverStripe requires Page and PageController classes in the project. These are +# generated here (not committed) to avoid duplicate class detection when the module +# is symlinked into vendor/. SilverStripe discovers modules by looking for _config/; +# without a project-root _config/, /app/src/ is not scanned and Page/PageController +# stay invisible to the class manifest (which breaks temp-database creation in tests). +RUN mkdir -p /app/src /app/_config \ + && printf ' /app/src/Page.php \ + && printf ' /app/src/PageController.php + +COPY app/_config/ /app/_config/ +COPY app/phpunit.xml.dist /app/phpunit.xml.dist +COPY app/phpstan.neon.dist /app/phpstan.neon.dist +COPY app/phpstan-php85.neon.dist /app/phpstan-php85.neon.dist +COPY app/rector.php /app/rector.php + +COPY entrypoint.sh /usr/local/bin/entrypoint.sh +RUN chmod +x /usr/local/bin/entrypoint.sh + +ENTRYPOINT ["entrypoint.sh"] diff --git a/.docker/app/_config/config.yml b/.docker/app/_config/config.yml new file mode 100644 index 0000000..3d07c13 --- /dev/null +++ b/.docker/app/_config/config.yml @@ -0,0 +1,3 @@ +--- +Name: app-e2e +--- diff --git a/.docker/app/composer.json b/.docker/app/composer.json new file mode 100644 index 0000000..74af7b8 --- /dev/null +++ b/.docker/app/composer.json @@ -0,0 +1,59 @@ +{ + "minimum-stability": "dev", + "prefer-stable": true, + "require": { + "silverstripe/recipe-cms": "^6.0", + "wedevelopnl/silverstripe-e2e": "*" + }, + "repositories": [ + { + "type": "path", + "url": "/module", + "options": { + "symlink": true + } + } + ], + "require-dev": { + "cambis/silverstan": "2.1.9", + "phpstan/extension-installer": "1.4.3", + "phpstan/phpstan": "2.2.1", + "phpstan/phpstan-deprecation-rules": "2.0.4", + "phpunit/phpunit": "11.5.55", + "rregeer/phpunit-coverage-check": "0.3.1", + "tomasvotruba/type-coverage": "2.2.1", + "wernerkrauss/silverstripe-rector": "1.3.0" + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "autoload-dev": { + "psr-4": { + "WeDevelop\\E2e\\Tests\\": "vendor/wedevelopnl/silverstripe-e2e/tests/" + } + }, + "extra": { + "project-files-installed": [ + ".htaccess", + "app/.htaccess", + "app/_config/mysite.yml", + "app/src/Page.php", + "app/src/PageController.php" + ], + "public-files-installed": [ + ".htaccess", + "index.php", + "web.config" + ] + }, + "config": { + "allow-plugins": { + "composer/installers": true, + "phpstan/extension-installer": true, + "silverstripe/recipe-plugin": true, + "silverstripe/vendor-plugin": true + } + } +} diff --git a/.docker/app/phpstan-php85.neon.dist b/.docker/app/phpstan-php85.neon.dist new file mode 100644 index 0000000..7cb9023 --- /dev/null +++ b/.docker/app/phpstan-php85.neon.dist @@ -0,0 +1,9 @@ +# Secondary PHPStan pass with the analysis target pinned to PHP 8.5, so +# target-gated checks (e.g. #[\NoDiscard]) run while the primary pass keeps +# verifying the 8.3 floor. +includes: + - phpstan.neon.dist +parameters: + phpVersion: + min: 80500 + max: 80599 diff --git a/.docker/app/phpstan.neon.dist b/.docker/app/phpstan.neon.dist new file mode 100644 index 0000000..fc9ed85 --- /dev/null +++ b/.docker/app/phpstan.neon.dist @@ -0,0 +1,13 @@ +parameters: + level: max + phpVersion: + min: 80300 + max: 80599 + paths: + - /module/src + type_coverage: + return_type: 100 + param_type: 100 + property_type: 100 + constant_type: 100 + declare: 100 diff --git a/.docker/app/phpunit.xml.dist b/.docker/app/phpunit.xml.dist new file mode 100644 index 0000000..7e4c4f0 --- /dev/null +++ b/.docker/app/phpunit.xml.dist @@ -0,0 +1,21 @@ + + + + + vendor/wedevelopnl/silverstripe-e2e/tests/Unit + + + vendor/wedevelopnl/silverstripe-e2e/tests/Integration + + + + + vendor/wedevelopnl/silverstripe-e2e/src + + + diff --git a/.docker/app/rector.php b/.docker/app/rector.php new file mode 100644 index 0000000..5e362da --- /dev/null +++ b/.docker/app/rector.php @@ -0,0 +1,34 @@ +withPaths([ + '/module/src', + ]) + ->withPreparedSets( + deadCode: true, + codeQuality: true, + typeDeclarations: true, + instanceOf: true, + earlyReturn: true, + rectorPreset: true, + ) + ->withPhpSets(php83: true) + ->withSets([ + SilverstripeSetList::CODE_STYLE, + SilverstripeLevelSetList::UP_TO_SS_6_0, + ]) + ->withSkip([ + // Subjective style choices we deliberately keep. + ChangeOrIfContinueToMultiContinueRector::class, + FlipTypeControlToUseExclusiveTypeRector::class, + PostIncDecToPreIncDecRector::class, + ]); diff --git a/.docker/compose.ci.yml b/.docker/compose.ci.yml new file mode 100644 index 0000000..de8694b --- /dev/null +++ b/.docker/compose.ci.yml @@ -0,0 +1,16 @@ +# CI-only overlay adding GitHub Actions layer caching + a mirrored db image. +# Layered after compose.yml by .github/workflows/ci.yml: +# COMPOSE_BAKE=true docker compose -f .docker/compose.yml -f .docker/compose.ci.yml build app +# CACHE_SCOPE namespaces the cache per PHP version so matrix jobs don't clobber +# each other. cache_to ignore-error=true lets fork/Dependabot PRs (no cache write +# access) degrade to an uncached build instead of failing. +services: + db: + image: mirror.gcr.io/library/mysql:8 + + app: + build: + cache_from: + - type=gha,scope=${CACHE_SCOPE:-app} + cache_to: + - type=gha,mode=max,scope=${CACHE_SCOPE:-app},ignore-error=true diff --git a/.docker/compose.yml b/.docker/compose.yml new file mode 100644 index 0000000..7fbca93 --- /dev/null +++ b/.docker/compose.yml @@ -0,0 +1,49 @@ +services: + app: + build: + context: . + dockerfile: Dockerfile + args: + PHP_VERSION: ${PHP_VERSION:-8.3} + volumes: + - ../composer.json:/module/composer.json:ro + - ../src:/module/src + - ../tests:/module/tests:ro + - ../_config:/module/_config:ro + - ../coverage:/app/coverage + - vendor:/app/vendor + healthcheck: + test: test -f /tmp/.app-ready + interval: 3s + start_period: 60s + retries: 20 + depends_on: + db: + condition: service_healthy + environment: + SS_DATABASE_SERVER: db + SS_DATABASE_NAME: silverstripe + SS_DATABASE_USERNAME: silverstripe + SS_DATABASE_PASSWORD: silverstripe + SS_DEFAULT_ADMIN_USERNAME: admin + SS_DEFAULT_ADMIN_PASSWORD: admin + SS_ENVIRONMENT_TYPE: dev + SS_PHPUNIT_FLUSH: 1 + + db: + image: mysql:8 + volumes: + - db-data:/var/lib/mysql + environment: + MYSQL_DATABASE: silverstripe + MYSQL_USER: silverstripe + MYSQL_PASSWORD: silverstripe + MYSQL_ROOT_PASSWORD: root + healthcheck: + test: mysqladmin ping -h localhost + interval: 5s + retries: 10 + +volumes: + vendor: + db-data: diff --git a/.docker/entrypoint.sh b/.docker/entrypoint.sh new file mode 100755 index 0000000..916f28c --- /dev/null +++ b/.docker/entrypoint.sh @@ -0,0 +1,13 @@ +#!/bin/sh +set -e + +composer install --no-interaction + +# Build the schema + class/config manifest so silverstan and sapphire tests have +# a ready environment. DB is guaranteed up (compose depends_on: db healthy). +vendor/bin/sake dev/build flush=1 + +touch /tmp/.app-ready + +# No webserver: block so `docker compose exec` can run tests/analysis in this container. +exec tail -f /dev/null diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e86edba --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.DS_Store +composer.lock +vendor/ + +# PHPUnit / coverage +.phpunit.cache/ +coverage/ diff --git a/Taskfile.yml b/Taskfile.yml new file mode 100644 index 0000000..56839d6 --- /dev/null +++ b/Taskfile.yml @@ -0,0 +1,102 @@ +version: '3' + +# Build/dev task runner for the SilverStripe E2E module. +# Install Task: https://taskfile.dev/installation + +vars: + COMPOSE: docker compose -f .docker/compose.yml + +tasks: + up: + desc: Start services (build if needed) + cmds: + - "{{.COMPOSE}} up -d --build --wait" + + down: + desc: Stop services + cmds: + - "{{.COMPOSE}} down" + + destroy: + desc: Stop services and remove volumes + cmds: + - "{{.COMPOSE}} down -v" + + build: + desc: Build images without starting + cmds: + - "{{.COMPOSE}} build" + + ensure-up: + desc: Ensure services are running and ready + cmds: + - "{{.COMPOSE}} exec app true 2>/dev/null || {{.COMPOSE}} up -d --build --wait" + + test: + desc: Run all PHP tests (unit + integration) + deps: [ensure-up] + cmds: + - "{{.COMPOSE}} exec app vendor/bin/phpunit" + + test-unit: + desc: Run PHP unit tests (no database or framework) + deps: [ensure-up] + cmds: + - "{{.COMPOSE}} exec app vendor/bin/phpunit --testsuite unit" + + test-integration: + desc: Run PHP integration tests (full SilverStripe environment) + deps: [ensure-up] + cmds: + - "{{.COMPOSE}} exec app vendor/bin/phpunit --testsuite integration" + + analyse: + desc: Run PHPStan static analysis + deps: [ensure-up] + cmds: + - "{{.COMPOSE}} exec app vendor/bin/phpstan analyse -c phpstan.neon.dist --memory-limit=512M" + + analyse-php85: + desc: Run PHPStan with the analysis target pinned to PHP 8.5 + deps: [ensure-up] + cmds: + - "{{.COMPOSE}} exec app vendor/bin/phpstan analyse -c phpstan-php85.neon.dist --memory-limit=512M" + + rector: + desc: Run Rector refactoring (applies changes) + deps: [ensure-up] + cmds: + - "{{.COMPOSE}} exec app vendor/bin/rector process" + + rector-dry: + desc: Run Rector in dry-run mode (preview only) + deps: [ensure-up] + cmds: + - "{{.COMPOSE}} exec app vendor/bin/rector process --dry-run" + + coverage: + desc: Run all tests with coverage (HTML + Clover XML) + deps: [ensure-up] + cmds: + - >- + {{.COMPOSE}} exec app vendor/bin/phpunit + --coverage-html coverage/html + --coverage-clover coverage/clover.xml + + coverage-check: + desc: Check PHP coverage meets the 90% threshold + deps: [coverage] + cmds: + - "{{.COMPOSE}} exec app vendor/bin/coverage-check coverage/clover.xml 90" + + flush: + desc: Clear SilverStripe cache + deps: [ensure-up] + cmds: + - "{{.COMPOSE}} exec app vendor/bin/sake flush" + + dev-build: + desc: Run dev/build to rebuild the database and manifest + deps: [ensure-up] + cmds: + - "{{.COMPOSE}} exec app vendor/bin/sake dev/build flush=1" diff --git a/composer.json b/composer.json index 271e404..ce282c8 100644 --- a/composer.json +++ b/composer.json @@ -14,13 +14,29 @@ }], "require": { "php": "^8.3", - "silverstripe/framework": "^6" + "silverstripe/framework": "^6", + "silverstripe/htmleditor-tinymce": "^1.1" + }, + "require-dev": { + "cambis/silverstan": "^2.1", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-deprecation-rules": "^2.0", + "phpunit/phpunit": "^11.3", + "rregeer/phpunit-coverage-check": "^0.3", + "tomasvotruba/type-coverage": "^2.0", + "wernerkrauss/silverstripe-rector": "^1.0" }, "autoload": { "psr-4": { "WeDevelop\\E2e\\": "src/" } }, + "autoload-dev": { + "psr-4": { + "WeDevelop\\E2e\\Tests\\": "tests/" + } + }, "config": { "allow-plugins": { "composer/installers": false diff --git a/coverage/.gitignore b/coverage/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/coverage/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/tests/Integration/.gitkeep b/tests/Integration/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/Unit/.gitkeep b/tests/Unit/.gitkeep new file mode 100644 index 0000000..e69de29 From 197552eeb12ea42f6203f331667023c1204cec15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erik=20Fr=C3=A8rejean?= Date: Fri, 3 Jul 2026 11:07:59 +0200 Subject: [PATCH 2/6] Fix SS6 TinyMCE FQCNs in build task; add integration test --- src/Tasks/GenerateTinyMCECombinedTask.php | 8 +++- tests/Integration/.gitkeep | 0 .../GenerateTinyMCECombinedTaskTest.php | 43 +++++++++++++++++++ 3 files changed, 49 insertions(+), 2 deletions(-) delete mode 100644 tests/Integration/.gitkeep create mode 100644 tests/Integration/GenerateTinyMCECombinedTaskTest.php diff --git a/src/Tasks/GenerateTinyMCECombinedTask.php b/src/Tasks/GenerateTinyMCECombinedTask.php index 7dc2d2d..e9b11e7 100644 --- a/src/Tasks/GenerateTinyMCECombinedTask.php +++ b/src/Tasks/GenerateTinyMCECombinedTask.php @@ -7,8 +7,8 @@ use SilverStripe\Core\Injector\Injector; use SilverStripe\Dev\BuildTask; use SilverStripe\Forms\HTMLEditor\HTMLEditorConfig; -use SilverStripe\Forms\HTMLEditor\TinyMCECombinedGenerator; -use SilverStripe\Forms\HTMLEditor\TinyMCEScriptGenerator; +use SilverStripe\TinyMCE\TinyMCECombinedGenerator; +use SilverStripe\TinyMCE\TinyMCEScriptGenerator; use SilverStripe\i18n\i18n; use SilverStripe\PolyExecution\PolyOutput; use Symfony\Component\Console\Command\Command; @@ -42,7 +42,11 @@ protected function execute(InputInterface $input, PolyOutput $output): int i18n::with_locale($locale, $doGenerate); } } else { + // @codeCoverageIgnoreStart + // Unreachable once the framework has booted (i18n is always autoloadable); + // retained only as a defensive fallback for a stripped runtime. $doGenerate(); + // @codeCoverageIgnoreEnd } $output->writeln('Generated TinyMCE configuration files'); diff --git a/tests/Integration/.gitkeep b/tests/Integration/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/tests/Integration/GenerateTinyMCECombinedTaskTest.php b/tests/Integration/GenerateTinyMCECombinedTaskTest.php new file mode 100644 index 0000000..a0bf1f0 --- /dev/null +++ b/tests/Integration/GenerateTinyMCECombinedTaskTest.php @@ -0,0 +1,43 @@ +run(new ArrayInput([]), $output); + + self::assertSame(Command::SUCCESS, $exitCode); + self::assertStringContainsString( + 'Generated TinyMCE configuration files', + $buffer->fetch(), + ); + } +} From 94c5103f7f713ee71cb5a29adbf4c4f7296e54c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erik=20Fr=C3=A8rejean?= Date: Fri, 3 Jul 2026 11:53:08 +0200 Subject: [PATCH 3/6] Guard non-TinyMCE editor configs; set container memory limit --- .docker/Dockerfile | 5 +++ src/Tasks/GenerateTinyMCECombinedTask.php | 10 ++++- .../GenerateTinyMCECombinedTaskTest.php | 44 +++++++++++++++++++ 3 files changed, 58 insertions(+), 1 deletion(-) diff --git a/.docker/Dockerfile b/.docker/Dockerfile index 81efbf6..ec9434b 100644 --- a/.docker/Dockerfile +++ b/.docker/Dockerfile @@ -8,6 +8,11 @@ RUN install-php-extensions intl gd mysqli pdo_mysql zip bcmath exif pcov # Measure coverage against the mounted module source only. RUN echo 'pcov.directory=/module/src' > /usr/local/etc/php/conf.d/99-pcov.ini +# The TinyMCE build task loads a translation catalogue per i18n locale (hundreds), +# so PHP's 128M default is insufficient; raise it container-wide so every invocation +# (task, CI running vendor/bin/phpunit directly, and manual runs) is covered. +RUN echo 'memory_limit=512M' > /usr/local/etc/php/conf.d/99-memory.ini + COPY --from=composer:latest /usr/bin/composer /usr/bin/composer WORKDIR /app diff --git a/src/Tasks/GenerateTinyMCECombinedTask.php b/src/Tasks/GenerateTinyMCECombinedTask.php index e9b11e7..06ea1cb 100644 --- a/src/Tasks/GenerateTinyMCECombinedTask.php +++ b/src/Tasks/GenerateTinyMCECombinedTask.php @@ -8,6 +8,7 @@ use SilverStripe\Dev\BuildTask; use SilverStripe\Forms\HTMLEditor\HTMLEditorConfig; use SilverStripe\TinyMCE\TinyMCECombinedGenerator; +use SilverStripe\TinyMCE\TinyMCEConfig; use SilverStripe\TinyMCE\TinyMCEScriptGenerator; use SilverStripe\i18n\i18n; use SilverStripe\PolyExecution\PolyOutput; @@ -31,7 +32,14 @@ protected function execute(InputInterface $input, PolyOutput $output): int /** @var TinyMCEScriptGenerator $generator */ $generator = Injector::inst()->create(TinyMCEScriptGenerator::class); foreach (array_keys($editorConfigs) as $identifier) { - $generator->getScriptURL(HTMLEditorConfig::get($identifier)); + $config = HTMLEditorConfig::get($identifier); + // Only TinyMCE-backed configs expose a combined script to pre-generate; + // skip any other HTMLEditorConfig implementation that may be registered. + if (!$config instanceof TinyMCEConfig) { + continue; + } + + $generator->getScriptURL($config); } }; diff --git a/tests/Integration/GenerateTinyMCECombinedTaskTest.php b/tests/Integration/GenerateTinyMCECombinedTaskTest.php index a0bf1f0..9baae0e 100644 --- a/tests/Integration/GenerateTinyMCECombinedTaskTest.php +++ b/tests/Integration/GenerateTinyMCECombinedTaskTest.php @@ -6,6 +6,8 @@ use PHPUnit\Framework\Attributes\CoversClass; use SilverStripe\Dev\SapphireTest; +use SilverStripe\Forms\HTMLEditor\HTMLEditorConfig; +use SilverStripe\Forms\HTMLEditor\TextAreaConfig; use SilverStripe\PolyExecution\PolyOutput; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\ArrayInput; @@ -20,6 +22,18 @@ final class GenerateTinyMCECombinedTaskTest extends SapphireTest // reads HTMLEditor config and writes combined TinyMCE assets to the filesystem. protected $usesDatabase = false; + // Identifier used to register a non-TinyMCE editor config; HTMLEditorConfig + // stores registrations in a static map, so it must be removed in tearDown to + // avoid leaking into other tests. + private const NON_TINYMCE_CONFIG_IDENTIFIER = 'e2e-plain'; + + protected function tearDown(): void + { + HTMLEditorConfig::set_config(self::NON_TINYMCE_CONFIG_IDENTIFIER, null); + + parent::tearDown(); + } + public function testTaskGeneratesTinyMceConfigAndReturnsSuccess(): void { $buffer = new BufferedOutput(); @@ -40,4 +54,34 @@ public function testTaskGeneratesTinyMceConfigAndReturnsSuccess(): void $buffer->fetch(), ); } + + public function testTaskSkipsNonTinyMceConfigsWithoutError(): void + { + // Register a concrete HTMLEditorConfig that is NOT a TinyMCEConfig so it + // appears in HTMLEditorConfig::get_available_configs_map(). The task must + // skip it (the instanceof guard's `continue`) rather than passing it to the + // TinyMCE script generator, which only accepts TinyMCEConfig instances. + HTMLEditorConfig::set_config( + self::NON_TINYMCE_CONFIG_IDENTIFIER, + TextAreaConfig::create(), + ); + + $buffer = new BufferedOutput(); + $output = new PolyOutput( + PolyOutput::FORMAT_ANSI, + OutputInterface::VERBOSITY_NORMAL, + false, + $buffer, + ); + + $task = new GenerateTinyMCECombinedTask(); + + $exitCode = $task->run(new ArrayInput([]), $output); + + self::assertSame(Command::SUCCESS, $exitCode); + self::assertStringContainsString( + 'Generated TinyMCE configuration files', + $buffer->fetch(), + ); + } } From c2e8de4ec60f2a54a3819cd4af7e9fae55e0901c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erik=20Fr=C3=A8rejean?= Date: Fri, 3 Jul 2026 12:12:41 +0200 Subject: [PATCH 4/6] Add GitHub Actions CI (PHP 8.3/8.4/8.5), docker mirror, dependabot --- .../actions/setup-docker-mirror/action.yml | 26 +++++ .github/dependabot.yml | 17 ++++ .github/workflows/ci.yml | 97 +++++++++++++++++++ 3 files changed, 140 insertions(+) create mode 100644 .github/actions/setup-docker-mirror/action.yml create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/ci.yml diff --git a/.github/actions/setup-docker-mirror/action.yml b/.github/actions/setup-docker-mirror/action.yml new file mode 100644 index 0000000..0d134c1 --- /dev/null +++ b/.github/actions/setup-docker-mirror/action.yml @@ -0,0 +1,26 @@ +name: Setup buildx with Docker Hub mirror +description: >- + Configure buildx so build-time docker.io pulls go through mirror.gcr.io + (Google's public pull-through cache for Docker Hub), avoiding Docker Hub's + anonymous rate limit. The buildx docker-container driver pulls base images + (php-cli, composer, the extension installer) and its own buildkit image + independently of the daemon's authenticated session; across the CI matrix + that trips HTTP 429. + + Daemon-side pulls (the db/mysql service at `compose up`) are pointed at the + mirror via an explicit image ref in compose.ci.yml instead. + +runs: + using: composite + steps: + # Export ACTIONS_CACHE_URL / ACTIONS_RUNTIME_TOKEN into the job env. GitHub + # only exposes these to JavaScript actions, not to `run:` steps — so without + # this, the `type=gha` cache used by `docker compose build` (a run step) + # silently no-ops and every build is cold. + - uses: crazy-max/ghaction-github-runtime@v3 + - uses: docker/setup-buildx-action@v3 + with: + driver-opts: image=mirror.gcr.io/moby/buildkit:buildx-stable-1 + buildkitd-config-inline: | + [registry."docker.io"] + mirrors = ["mirror.gcr.io"] diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..1c815f0 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,17 @@ +version: 2 + +updates: + - package-ecosystem: composer + directory: / + schedule: + interval: daily + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + + - package-ecosystem: docker + directory: /.docker + schedule: + interval: weekly diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..801b27a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,97 @@ +name: CI + +on: + push: + branches: ['main', '6'] + pull_request: + branches: ['main', '6'] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + static-analysis: + name: Static Analysis + runs-on: ubuntu-latest + env: + COMPOSE: docker compose -f .docker/compose.yml -f .docker/compose.ci.yml + COMPOSE_BAKE: 'true' + CACHE_SCOPE: app-php8.3 + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + steps: + - uses: actions/checkout@v4 + - name: Log in to Docker Hub + if: env.DOCKERHUB_USERNAME != '' + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - uses: arduino/setup-task@v2 + with: + version: 3.x + repo-token: ${{ secrets.GITHUB_TOKEN }} + - uses: ./.github/actions/setup-docker-mirror + - name: Build image (GHA layer cache) + run: $COMPOSE build app + - name: Start services + run: $COMPOSE up -d --wait app + - run: task analyse + - run: task analyse-php85 + - run: task rector-dry + + php-qa: + name: PHP QA (PHP ${{ matrix.php }}) + runs-on: ubuntu-latest + permissions: + contents: read + checks: write + strategy: + fail-fast: false + matrix: + php: ['8.3', '8.4', '8.5'] + env: + PHP_VERSION: ${{ matrix.php }} + COMPOSE: docker compose -f .docker/compose.yml -f .docker/compose.ci.yml + COMPOSE_BAKE: 'true' + CACHE_SCOPE: app-php${{ matrix.php }} + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + steps: + - uses: actions/checkout@v4 + - name: Log in to Docker Hub + if: env.DOCKERHUB_USERNAME != '' + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - uses: arduino/setup-task@v2 + with: + version: 3.x + repo-token: ${{ secrets.GITHUB_TOKEN }} + - uses: ./.github/actions/setup-docker-mirror + - name: Build image (GHA layer cache) + run: $COMPOSE build app + - name: Start services + run: $COMPOSE up -d --wait app + # PHP 8.3 owns the coverage gate; 8.4/8.5 run the suites without pcov overhead. + - if: matrix.php == '8.3' + name: Run tests with coverage + 90% gate + run: | + $COMPOSE exec -T app vendor/bin/phpunit \ + --log-junit coverage/junit.xml \ + --coverage-clover coverage/clover.xml + $COMPOSE exec -T app vendor/bin/coverage-check coverage/clover.xml 90 + - if: matrix.php != '8.3' + name: Run tests + run: >- + $COMPOSE exec -T app vendor/bin/phpunit + --log-junit coverage/junit.xml + - uses: dorny/test-reporter@v1 + if: always() + with: + name: PHPUnit Results (PHP ${{ matrix.php }}) + path: coverage/junit.xml + reporter: java-junit From ae2151e3ce718532df3c7188f664229634aa1cd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erik=20Fr=C3=A8rejean?= Date: Fri, 3 Jul 2026 13:17:05 +0200 Subject: [PATCH 5/6] Install git + unzip for resilient composer install; capture app logs on CI startup failure --- .docker/Dockerfile | 9 +++++++++ .github/workflows/ci.yml | 14 ++++++++++++-- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/.docker/Dockerfile b/.docker/Dockerfile index ec9434b..1647f3f 100644 --- a/.docker/Dockerfile +++ b/.docker/Dockerfile @@ -1,6 +1,15 @@ ARG PHP_VERSION=8.3 FROM php:${PHP_VERSION}-cli +# git + unzip: the official php:*-cli image ships neither. Composer needs git to fall +# back to a source clone when a dist (zipball) download fails transiently — without it a +# single failed download aborts the whole install ("Source fallback is disabled. Not +# trying alternative sources."). unzip gives faster, more reliable archive extraction +# than composer's PHP-zip fallback. +RUN apt-get update \ + && apt-get install -y --no-install-recommends git unzip \ + && rm -rf /var/lib/apt/lists/* + # mlocati's installer builds the needed extensions across all target PHP versions. COPY --from=mlocati/php-extension-installer /usr/bin/install-php-extensions /usr/local/bin/ RUN install-php-extensions intl gd mysqli pdo_mysql zip bcmath exif pcov diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 801b27a..ee41eb0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,7 +38,12 @@ jobs: - name: Build image (GHA layer cache) run: $COMPOSE build app - name: Start services - run: $COMPOSE up -d --wait app + run: | + $COMPOSE up -d --wait app || { + echo "::group::app container logs"; $COMPOSE logs app; echo "::endgroup::" + $COMPOSE ps -a + exit 1 + } - run: task analyse - run: task analyse-php85 - run: task rector-dry @@ -75,7 +80,12 @@ jobs: - name: Build image (GHA layer cache) run: $COMPOSE build app - name: Start services - run: $COMPOSE up -d --wait app + run: | + $COMPOSE up -d --wait app || { + echo "::group::app container logs"; $COMPOSE logs app; echo "::endgroup::" + $COMPOSE ps -a + exit 1 + } # PHP 8.3 owns the coverage gate; 8.4/8.5 run the suites without pcov overhead. - if: matrix.php == '8.3' name: Run tests with coverage + 90% gate From 2410d7ccbe285bb8c248b578d60546d88b41dbc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erik=20Fr=C3=A8rejean?= Date: Fri, 3 Jul 2026 13:25:25 +0200 Subject: [PATCH 6/6] Fix duplicate 'app' module: mark mimevalidator project file installed --- .docker/app/composer.json | 1 + 1 file changed, 1 insertion(+) diff --git a/.docker/app/composer.json b/.docker/app/composer.json index 74af7b8..22bf89a 100644 --- a/.docker/app/composer.json +++ b/.docker/app/composer.json @@ -38,6 +38,7 @@ "project-files-installed": [ ".htaccess", "app/.htaccess", + "app/_config/mimevalidator.yml", "app/_config/mysite.yml", "app/src/Page.php", "app/src/PageController.php"