CI/CD у моєму Laravel-проєкті - це pipeline, який перевіряє зміни ще до того, як вони потрапляють у потрібне середовище. Я використовую його для build, перевірки якості коду, тестів і контрольованого deploy.
Що для мене означає CI/CD
Коли я тільки починала працювати з CI/CD, він легко сприймався як щось на кшталт pipeline, який запускає deploy. У реальному проєкті я дивлюся на нього трохи інакше.
CI (Continuous Integration - безперервна інтеграція) для мене - це автоматична перевірка змін: чи збирається backend і frontend, чи проходить static analysis, чи не зламані тести.
CD (Continuous Delivery / Continuous Deployment - безперервна доставка / безперервне розгортання) починається пізніше - коли вже перевірений код потрібно доставити на development або production.
Тому головна користь мого pipeline навіть не в тому, що він щось деплоїть. Для мене важливіше, що він не дозволяє рухатися далі, якщо один із потрібних етапів не пройшов.
Раніше я вже порівнювала, як для мене виглядала ця робота через Bitbucket CI Bot і через GitLab Pipeline.
Як побудований мій pipeline

У самому .gitlab-ci.yml у мене визначено п’ять stages:
infrastructure
build
quality
test
deploy
Але це не означає, що всі п’ять я бачу в кожному pipeline. Наприклад, на зображенні вище є тільки build, quality і test. Infrastructure потрібен лише за певних змін, а deploy має власні умови запуску.
Мені подобається така структура, бо кожен stage відповідає на окреме просте питання: чи готове середовище, чи збирається проєкт, чи нормальний код, чи проходять тести і чи можна його деплоїти.
stages: - infrastructure - build - quality - test - deployКоли я запускаю pipeline
workflow: rules: - if: '$CI_PIPELINE_SOURCE == "web" && $RUN_ALL == "true"' - if: '$CI_PIPELINE_SOURCE == "web" && $FORCE_CI_IMAGE_BUILD == "true"' - if: '$CI_PIPELINE_SOURCE == "web"' when: never - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "development"' - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "main"' - when: neverТут я свідомо не створюю pipeline на кожен звичайний push у feature branch.
Merge Request отримує свій pipeline. Push у development потрібен для deployment на dev, а push у main - для production flow. Окремо я можу вручну запустити повну перевірку через RUN_ALL або перебудувати PHP CI image через FORCE_CI_IMAGE_BUILD.
Усе інше завершується правилом when: never.
Для мене це простий спосіб не запускати jobs без причини.
Build: спочатку я перевіряю, що проєкт взагалі збирається
backend_build: stage: build script: - 'composer validate --strict' - > composer install --no-interaction --prefer-dist --no-progress --no-dev --optimize-autoloader - 'composer check-platform-reqs --no-dev' - 'php artisan optimize' - 'php artisan about' frontend_build: stage: build image: node:24-bookworm script: - 'npm ci --cache "$NPM_CONFIG_CACHE" --prefer-offline' - 'npm run build' - 'test -s public/build/manifest.json' - 'test -s bootstrap/ssr/ssr.js'Backend і frontend я перевіряю окремо.
Для backend я перевіряю Composer configuration, встановлюю production dependencies, перевіряю platform requirements і запускаю Laravel optimize.
Для frontend використовую Node 24, виконую npm ci і production build.
Після frontend build я додатково перевіряю, що реально створилися public/build/manifest.json і SSR bundle. Тобто для мене успішна команда npm run build сама по собі ще не є достатньою перевіркою.
Quality: окремо перевіряю код до запуску тестів
phpstan: stage: quality script: - './vendor/bin/phpstan analyse --memory-limit=1G --no-progress' php_cs_fixer: stage: quality script: - > ./vendor/bin/php-cs-fixer fix --dry-run --diff --verbose --using-cache=noНа цьому варіанті pipeline у quality stage в мене були PHPStan і PHP-CS-Fixer. PHPStan ловив проблеми static analysis, а formatter перевіряв стиль коду без автоматичного переписування файлів у CI.
Пізніше я прибрала PHP-CS-Fixer і перейшла на Laravel Pint. Сам принцип pipeline від цього не змінився: formatting check усе одно залишається окремою quality-перевіркою, просто інструмент став іншим.
Про цю міграцію я окремо написала у статті Laravel Pint уже був у проєкті: чому я вирішила прибрати PHP-CS-Fixer.
Tests: я не складаю всі тести в один job
tests_unit: stage: test script: - > php artisan test --testsuite=Unit --log-junit=reports/unit.xml tests_integration: stage: test script: - > php -d memory_limit=768M artisan test --testsuite=Integration --log-junit=reports/integration.xml tests_feature: stage: test script: - > php -d memory_limit=768M artisan test --testsuite=Feature --log-junit=reports/feature.xmlUnit, Integration і Feature tests у мене запускаються окремими jobs.
Для Integration і Feature я піднімаю MySQL 8.0.46, чекаю, поки база стане доступною, а потім виконую php artisan migrate:fresh --force.
Unit tests не потребують цього MySQL setup, тому немає сенсу змушувати їх чекати зайву інфраструктуру.
Ще один плюс такого поділу для мене - коли pipeline падає, я одразу бачу, в якій групі тестів проблема.
Я не запускаю всі jobs після кожної зміни
Одна з речей, яку я поступово додала в pipeline:
- rules: changes
Якщо я змінюю тільки frontend, немає сенсу без причини запускати весь backend test flow. Якщо змінюється PHP runtime, Composer dependencies, application code або test infrastructure - запускаються відповідні backend jobs.
Для release Merge Request у main або після зміни самого .gitlab-ci.yml перевірка ширша. А коли мені потрібна повна ручна перевірка незалежно від changed files, для цього є RUN_ALL.
Для мене хороший pipeline не той, який запускає якомога більше jobs. Він має запускати саме ті перевірки, які потрібні для конкретної зміни.
Deploy: development автоматичний, production - ручний
deploy_development: rules: - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "development"' when: on_success - when: never deploy_production: rules: - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "main"' when: manual allow_failure: false - when: neverДля development я дозволяю автоматичний deploy після успішного push pipeline у development. Для production у main я залишила ручний запуск.
Сам deployment у мене робить значно більше, ніж git pull:
збирає browser і SSR assets;
передає їх на сервер;
встановлює production Composer dependencies;
запускає migrations;
Laravel optimize;
restart queue;
перезапускає Reverb і SSR через Supervisor;
а в кінці виконує healthcheck.
Окремо мені довелося розібратися з доступом Composer до приватних GitLab dependencies.
Про схожу проблему з Composer, GitLab, Docker і SSH я вже писала в окремій статті.
Тому для мене CI/CD - це не просто deploy
Коли дивишся тільки на останній stage, легко подумати, що CI/CD потрібен головним чином для автоматичного deployment. У моєму проєкті найбільшу цінність я бачу раніше.
Pipeline поетапно відповідає мені: чи збирається проєкт, чи проходять quality checks, чи зелені тести і тільки потім - чи можна рухатися до deploy.
Саме тому я не намагалася зробити один великий job з десятком команд. Мені набагато зручніше бачити build, quality, test і deploy окремо та одразу розуміти, на якому етапі щось пішло не так.



