
В этом лонгриде я расскажу немного теории о CI/CD, но в основном это будут практические примеры и советы, в первую очередь полезные для PHP backend разработчиков, однако некоторые инструменты подходят и для других языков, и вы можете уловить общую идею, как писать пайплайны
Содержание
Почему CI/CD полезен и для бизнеса и для разработки
Доклад о том, зачем использовать пайплайны?
CI/CD расшифровывается как Continuous Integration / Continuous Delivery.
Под первой частью подразумевается постоянная интеграция нового кода в основную ветку (чему способствуют проверки качества кода перед вливанием).
Под вторым подразмевается частый деплой новых версий деплоя (например, три раза в неделю вместо одного раза в месяц),
чему способствуют:
-
проверки качества кода (не боимся, что не дотестили)
-
автоматическое развертывания приложения (всем видно доехало или нет, снижаем человеческий фактор, меньше рутинной работы)
Цена багов и уязвимостей найденных на проде несомненно выше таковых, найденых еще до того, как код был смержен в master,
поэтому имеет и коммерческий смысл делать shift left (раннее включение проверок в процесс разработки) в отношении проверки кода на качество и безопасность
Часто проверки кода располагают в pre-commit hooks, это менее надежно, так как разработчик скорее всего рано или поздно отключит их,
плюс это замедляют работу в feature ветке, когда ты хочешь сначала накидать решение которое работает,
а потом уже отрефакторить его, чтоб оно было более поддерживаемое
Интеграция различных инструментов контроля качества кода прокачивает ваши харды, потому что дает фидбек, что вы плохо написали и как это исправить
Чем больше проверок в пайплайне, тем меньше смысла в проведении code-review, потому что если пайплайн зеленый, то уже малая вероятность неправильных типов / кодстайла / архитектуры.
Хотя, конечно могут оставаться неоптимальные решения, неправильно понятая бизнес логика
Чем больше проверок в пайплайне, тем меньше проявляется Fear driven development — если ваши изменения прошли сквозь пайплайн,
то вряд ли вы написали что-то, что сразу упадет. А если и упадет, то виноват пайплайн, который это не нашел.
Стоит задуматься, как можно изменить пайплайн, чтобы он в следующий раз не пропустил такой код
Пишем пайплайн
Чем больше проверок в пайплайне, тем медленнее вносятся изменения в код, поэтому не рекомендую затаскивать все возможные инструменты сразу.
Многие инструменты имеют функционал baseline, другие инструменты имеют настраиваемую сложность или фильтры найденных уязвимостей, то облегчает их интеграцию.
Часть расписанных здесь джоб актуальна только для symfony (например di, schema validate)
Мы будем рассматривать GitLab CI/CD, потому что по моему опыту самый распространенный инструмент в коммерческой разработке
Джобы в stage запускаются параллельно, если не указать иное, поэтому нет смысла располагать их из расчета fail fast

Я расположу инструменты в порядке важности внедрения в проект (на мой субъективный взгляд)
Этапы (stages) пайплайна будут следующие:

Пайплайн лучше писать в pipeline editor прямо в интерфейсе GitLab, так как там сразу есть валидация конфига
Описываются наши stages вот так
stages:
- build
- test
- deploy
- DAST
Каждый stage содержит набор задач (jobs), каждая задача может запускать несколько команд
GitLab Runner автоматически (если не указано иное) клонирует ваш репозиторий в контейнер с джобой, прежде чем выполнять указанный script
Build
Нам нужен образ приложения, в котором будем выполнять проверки качества кода.
Но как правило такой образ вы не хотите деплоить, так как установлены dev зависимости, возможно включен xdebug и т.д.
Поэтому мы будем собирать 2 образа
Для сборки образа нам нужен Docker daemon, при этом сама задача (job) выполняется внутри Docker-контейнера поэтому нам нужен dind (Docker-in-Docker),
который запускает docker daemon в себе, и мы сможем использовать docker команды.
Для этого этапа нам понадобится в Settings -> CI/CD -> Variables создать 2 переменные:
-
DEV_ENV_FILE — с содержанием .env.local для dev стенда
-
PROD_ENV_FILE — с содержанием .env.local для prod стенда
Значения таких переменных могут выглядеть следующим образом:
APP_ENV=prod
FOO=BAR
Чтобы не заморачиваться с установкой в этот контейнер композера, я вынес билд приложения в отдельную джобу:
build_dev_dependencies:
stage: build
image: composer:latest
before_script:
- echo "$DEV_ENV_FILE" > .env.local
script:
- composer install --no-interaction
artifacts:
paths:
- . # приложение автоматически клонируется в job, добавим в артефакт чтоб не клонировать дважды и не было проблем с workdir, + установленные зависимости
Сборка образа:
build_dev_image:
services:
- name: docker:dind
alias: dind
image: docker:20.10.16
stage: build
variables:
GIT_STRATEGY: none # отключаем клонирование приложения в контейнер
before_script:
- docker login -u gitlab-ci-token -p $CI_JOB_TOKEN $CI_REGISTRY
script:
- docker build -t $DEV_IMAGE ./.docker/dev
- docker push $DEV_IMAGE # пушим image в registry гитлаба
needs: [build_dev_dependencies] # будет ждать пока build_dev_dependencies job не будет выполнена
По большей части то же самое сделаем для prod сборки:
build_prod_dependencies:
stage: build
image: composer:latest
variables:
APP_ENV: prod
APP_DEBUG: 0
before_script:
- echo "$PROD_ENV_FILE" > .env.local
script:
- composer install --no-dev --optimize-autoloader --no-interaction
- composer dump-env prod
artifacts:
paths:
- .
only:
- tags
Здесь мы не устанавливаем dev зависимости, и ускоряем работу autoloader: https://getcomposer.org/doc/articles/autoloader-optimization.md#optimization-level-1-class-map-generation
И оптимизируем чтение .env* файлов: https://symfony.com/doc/current/deployment.html#b-configure-your-environment-variables
Так же мы не хотим на каждый чих собирать prod сборку, поэтому конфигурируем запуск только когда был выпущен релиз (а значит и тег):
only:
- tags
Сборка образа:
build_prod_image:
services:
- name: docker:dind
alias: dind
image: docker:20.10.16
stage: build
variables:
GIT_STRATEGY: none
before_script:
- docker login -u gitlab-ci-token -p $CI_JOB_TOKEN $CI_REGISTRY
script:
- docker build -t $PROD_IMAGE ./.docker/prod
- docker push $PROD_IMAGE
- docker push $CI_REGISTRY_IMAGE:latest
needs: [build_prod_dependencies]
only:
- tags
Обратите внимание, что собирается с другого dockerfile:
- docker build -t $PROD_IMAGE ./.docker/prod
Теперь эти образы мы можем переиспользовать в дальнейших джобах, стягивая их с registry.
Test
Дисклеймер:
-
я не буду использовать infection, потому что предпочитаю писать функциональные тесты на целые эндпойнты и не на каждый edgecase. если же у вас library / DDD project, использование мутационного тестирования сильно вырастает (как мне кажется)
-
я не буду использовать линтеры для конфигов (например dotenv-linter), потому что пока не вижу много пользы
Если какие-то инструменты не хотят ставиться вместе, рекомендую поглядеть в сторону https://github.com/bamarni/composer-bin-plugin
Для обеспечения безопасности (DevSecOps) используются джобы с Composer, Kics, Trivy, Gitleaks, Nuclei
PHP-CS-Fixer
https://github.com/PHP-CS-Fixer/PHP-CS-Fixer
Это линтер. Замечательно то, что он правит все найденные несоответствия конфигу автоматически, поэтому самый легкий для внедрения,
в то же время очень важный, так как больше никогда вам не придется думать об оформлении кода
cs:
stage: test
image: $DEV_IMAGE
variables:
GIT_STRATEGY: none
script:
- ./vendor/bin/php-cs-fixer -v --config=.php-cs-fixer.dist.php fix --dry-run --stop-on-violation --diff
Используем флаг --dry-run
, чтобы в пайплайне у нас ничего не правилось, но просто находились ошибки.
--stop-on-violation
нужен для оптимизации — после первой же найденной ошибки мы можем упасть, и не искать остальные
Пример конфига
.php-cs-fixer.dist.php
<?php
declare(strict_types=1);
use PhpCsFixer\Config;
use PhpCsFixer\Finder;
use PHPyh\CodingStandard\PhpCsFixerCodingStandard;
$finder = (new Finder())
->in(__DIR__)
->exclude('var')
->append([
__FILE__,
__DIR__ . '/bin/console',
]);
$config = (new Config())
->setCacheFile(__DIR__ . '/var/.php-cs-fixer.cache')
->setFinder($finder);
(new PhpCsFixerCodingStandard())->applyTo($config);
return $config;
Пример violation

PHPUnit
phpunit:
stage: test
image: $DEV_IMAGE
services: # нам нужен контейнер с базой данных для тестов
- name: postgres:14
alias: postgres
variables:
APP_ENV: test
DATABASE_URL: "pgsql://postgres:postgres@postgres:5432/test_db"
POSTGRES_DB: test_db
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
GIT_STRATEGY: none
before_script:
- apt-get update && apt-get install -y postgresql-client
- until pg_isready -h postgres -p 5432 -U postgres; do sleep 1; done # ждем пока база внутри контейнера будет готова
- bin/console doctrine:database:create --if-not-exists
- bin/console doctrine:migrations:migrate --no-interaction
script:
- XDEBUG_MODE=coverage php ./vendor/bin/phpunit --colors=never --coverage-text --coverage-cobertura=coverage.cobertura.xml --log-junit phpunit-report.xml --do-not-cache-result
coverage: '/^\s*Lines:\s*\d+.\d+\%/'
artifacts:
when: always
reports:
junit: phpunit-report.xml
coverage_report:
coverage_format: cobertura
path: coverage.cobertura.xml
флаг --colors=never
нужен чтобы проще доставать регуляркой покрытие
--coverage-text
выводит результат выполнения в консоль
--do-not-cache-result
оптимизация
--log-junit phpunit-report.xml
— опционально, выводит репорт в UI гитлаба:

пример конфига
phpunit.xml.dist
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="tests/bootstrap.php"
cacheDirectory="var/phpunit"
requireCoverageMetadata="true"
beStrictAboutOutputDuringTests="true"
failOnRisky="true"
failOnWarning="true"
>
<php>
<ini name="display_errors" value="1"/>
<ini name="error_reporting" value="-1"/>
<server name="APP_ENV" value="test" force="true"/>
</php>
<testsuites>
<testsuite name="default">
<directory>tests</directory>
</testsuite>
</testsuites>
<source restrictDeprecations="true" restrictNotices="true" restrictWarnings="true">
<include>
<directory>src</directory>
</include>
</source>
</phpunit>
Composer
composer:
variables:
GIT_STRATEGY: none
stage: test
image: $DEV_IMAGE
script:
- composer normalize --diff --dry-run
- composer validate
- vendor/bin/composer-require-checker check --config-file=composer-require-checker.json
- php8.2 vendor/bin/composer-unused
- composer audit
- composer check-platform-reqs
composer normalize форматирует composer.json
в стандартный вид — например, сортирует поля в порядке востребованности — редко используемые поля (например repositories
) ставятся ниже чем часто используемые (например requires
)
composer validate — валидирует composer.json
против схемы, и проверяет синхронизацию с composer.lock
composer-require-checker — проверяет что вы не используете в коде транзитивные зависимости, чтобы вы могли явно добавить их в require
, дабы в один момент они не выпали из сборки
vendor/bin/composer-unused — проверяет, что у вас не стоит пакетов, которые вы нигде не используете
composer audit — проверяет наличие уязвимостей в установленных библиотеках (на удивление часто падает — раз в каждые месяца 2)
composer check-platform-reqs — проверяет, что на сервере установлены все необходимые для работы приложения расширения
Пример конфига для composer-unused
composer-unused.php
<?php
declare(strict_types=1);
use ComposerUnused\ComposerUnused\Configuration\Configuration;
use ComposerUnused\ComposerUnused\Configuration\NamedFilter;
return static fn(Configuration $config): Configuration => $config
->addNamedFilter(NamedFilter::fromString('baldinof/roadrunner-bundle'))
->addNamedFilter(NamedFilter::fromString('doctrine/doctrine-migrations-bundle'))
->addNamedFilter(NamedFilter::fromString('phpstan/phpdoc-parser'))
->addNamedFilter(NamedFilter::fromString('revolt/event-loop-adapter-react'))
->addNamedFilter(NamedFilter::fromString('symfony/dotenv'))
->addNamedFilter(NamedFilter::fromString('symfony/flex'))
->addNamedFilter(NamedFilter::fromString('symfony/monolog-bundle'))
->addNamedFilter(NamedFilter::fromString('symfony/runtime'))
->addNamedFilter(NamedFilter::fromString('symfony/security-bundle'));
Пример конфига для composer-require-checker
composer-require-checker.json
{
"symbol-whitelist" : [
"Doctrine\\Bundle\\FixturesBundle\\Fixture",
"Doctrine\\Bundle\\FixturesBundle\\FixtureGroupInterface"
]
}
Psalm
https://github.com/vimeo/psalm
psalm:
variables:
GIT_STRATEGY: none
stage: test
image: $DEV_IMAGE
script:
- vendor/bin/psalm
Пример violations

Пример конфига
psalm.xml.dist
<?xml version="1.0"?>
<psalm
cacheDirectory="var/psalm"
checkForThrowsDocblock="true"
checkForThrowsInGlobalScope="true"
disableSuppressAll="true"
ensureArrayStringOffsetsExist="true"
errorLevel="1"
findUnusedCode="false"
findUnusedBaselineEntry="true"
findUnusedPsalmSuppress="true"
findUnusedVariablesAndParams="true"
memoizeMethodCallResults="true"
reportMixedIssues="true"
sealAllMethods="true"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="https://getpsalm.org/schema/config"
xsi:schemaLocation="https://getpsalm.org/schema/config vendor/vimeo/psalm/config.xsd"
>
<projectFiles>
<directory name="config"/>
<directory name="public"/>
<directory name="src"/>
<directory name="telephantast"/>
<directory name="tests"/>
<file name="bin/console"/>
<ignoreFiles>
<directory name="var"/>
<directory name="vendor"/>
</ignoreFiles>
</projectFiles>
<forbiddenFunctions>
<function name="dd"/>
<function name="die"/>
<function name="dump"/>
<function name="echo"/>
<function name="empty"/>
<function name="eval"/>
<function name="exit"/>
<function name="print"/>
<function name="print_r"/>
<function name="var_export"/>
</forbiddenFunctions>
<issueHandlers>
<MissingThrowsDocblock>
<errorLevel type="suppress">
<directory name="tests"/>
</errorLevel>
</MissingThrowsDocblock>
<MixedAssignment errorLevel="suppress"/>
</issueHandlers>
<ignoreExceptions>
<classAndDescendants name="LogicException"/>
<classAndDescendants name="RuntimeException"/>
<classAndDescendants name="ReflectionException"/>
<classAndDescendants name="JsonException"/>
<classAndDescendants name="Doctrine\DBAL\Exception"/>
<classAndDescendants name="Psr\Container\ContainerExceptionInterface"/>
</ignoreExceptions>
<stubs>
<file name="stubs/Bunny/AbstractClient.phpstub"/>
<file name="stubs/Bunny/Async/Client.phpstub"/>
<file name="stubs/Bunny/Channel.phpstub"/>
<file name="stubs/Psr/Container/ContainerInterface.phpstub"/>
<file name="stubs/React/Promise/PromiseInterface.phpstub"/>
</stubs>
</psalm>
DI lint
Нужно, чтобы проверить, что контейнер symfony компилируется корректно в prod режиме
di_lint:
variables:
GIT_STRATEGY: none
stage: test
image: $DEV_IMAGE
script:
- bin/console cache:clear --env=prod
- bin/console lint:container --env=prod
Doctrine schema validate
Проверяет корректность маппингов доктрины, без соединения с бд
schema_validate:
variables:
GIT_STRATEGY: none
stage: test
image: $DEV_IMAGE
script:
- bin/console doctrine:schema:validate --skip-sync
Check coverage
Здесь мы хотим запретить снижение покрытия тестами
Для работы джобы надо создать access token с правами read_api
, выпустить его можно в профиле пользователя
Далее надо добавить токен в переменные для пайплайна, для этого нужно перейти в репозиторий -> Settings -> CI/CD -> Variables,
добавить переменную с ключом CHECK_COVERAGE_TOKEN
и значением в виде токена.
Стоит сделать ее masked
, чтоб не было видно в логах и убрать флаг protected variable
, чтоб оно работало со всех веток
Code coverage
сохраняется как один из параметров джобы, и мы можем его достать. Для всех джоб кроме phpunit там будет null
Вот по такому запросу https://gitlab.com/api/v4/projects/67384433/pipelines/1689770968/jobs получаем массив таких объектов:
job.json
{
"id": 9251826755,
"status": "success",
"stage": "test",
"name": "phpunit",
"ref": "develop",
"tag": false,
"coverage": 0.73,
"allow_failure": false,
"created_at": "2025-02-26T15:06:42.001Z",
"started_at": "2025-02-26T15:13:05.482Z",
"finished_at": "2025-02-26T15:15:02.498Z",
"erased_at": null,
"duration": 117.015687,
"queued_duration": 2.547127,
"user": {
"id": 22031530,
"username": "savinmikhail",
"name": "Mikhail",
"state": "active",
"locked": false,
"avatar_url": "https://secure.gravatar.com/avatar/b875f06f4a5b59fb1c051d348aee7c06c32fa04277ac9dfef77a1f01a72ebc87?s=80&d=identicon",
"web_url": "https://gitlab.com/savinmikhail",
"created_at": "2024-07-11T14:59:57.200Z",
"bio": "",
"location": "",
"public_email": null,
"skype": "",
"linkedin": "",
"twitter": "",
"discord": "",
"website_url": "",
"organization": "",
"job_title": "",
"pronouns": null,
"bot": false,
"work_information": null,
"followers": 0,
"following": 0,
"local_time": null
},
"commit": {
"id": "7fc44cab4baefb4745d2d4e0b9641059d99570bd",
"short_id": "7fc44cab",
"created_at": "2025-02-26T22:06:35.000+07:00",
"parent_ids": [
"58f54851649537ae88f1e89e798deb90b3397689"
],
"title": "update .gitlab-ci.yml",
"message": "update .gitlab-ci.yml\n",
"author_name": "Mikhail",
"author_email": "salazar290720035017@gmail.com",
"authored_date": "2025-02-26T22:06:35.000+07:00",
"committer_name": "Mikhail",
"committer_email": "salazar290720035017@gmail.com",
"committed_date": "2025-02-26T22:06:35.000+07:00",
"trailers": {
},
"extended_trailers": {
},
"web_url": "https://gitlab.com/savinmikhail1/online-shop/-/commit/7fc44cab4baefb4745d2d4e0b9641059d99570bd"
},
"pipeline": {
"id": 1689770968,
"iid": 40,
"project_id": 67384433,
"sha": "7fc44cab4baefb4745d2d4e0b9641059d99570bd",
"ref": "develop",
"status": "running",
"source": "push",
"created_at": "2025-02-26T15:06:41.879Z",
"updated_at": "2025-02-26T15:06:43.058Z",
"web_url": "https://gitlab.com/savinmikhail1/online-shop/-/pipelines/1689770968"
},
"web_url": "https://gitlab.com/savinmikhail1/online-shop/-/jobs/9251826755",
"project": {
"ci_job_token_scope_enabled": false
},
"artifacts": [
{
"file_type": "cobertura",
"size": 2623,
"filename": "cobertura-coverage.xml.gz",
"file_format": "gzip"
}
],
"runner": {
"id": 12270845,
"description": "1-green.saas-linux-small-amd64.runners-manager.gitlab.com/default",
"ip_address": null,
"active": true,
"paused": false,
"is_shared": true,
"runner_type": "instance_type",
"name": "gitlab-runner",
"online": true,
"status": "online"
},
"runner_manager": {
"id": 57464191,
"system_id": "s_deaa2ca09de7",
"version": "17.7.0~pre.103.g896916a8",
"revision": "896916a8",
"platform": "linux",
"architecture": "amd64",
"created_at": "2024-12-20T16:35:28.539Z",
"contacted_at": "2025-02-26T15:15:07.123Z",
"ip_address": "10.1.5.248",
"status": "online"
},
"artifacts_expire_at": "2025-03-28T15:13:53.503Z",
"archived": false,
"tag_list": []
}
Мы таким образом вычленяем покрытие для текущего пайплайна и для последнего с TARGET_BRANCH — скорее всего master
check_coverage:
image: alpine:latest
stage: test
needs: [phpunit]
variables:
JOB_NAME: phpunit
TARGET_BRANCH: master
GIT_STRATEGY: none
before_script:
- apk add --update --no-cache curl jq
rules:
- if: '$CI_COMMIT_BRANCH != $TARGET_BRANCH'
script:
- |
# Get latest pipeline ID from the target branch using the PAT
TARGET_PIPELINE_JSON=$(curl -s --header "PRIVATE-TOKEN: $CHECK_COVERAGE_TOKEN" "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/pipelines?ref=${TARGET_BRANCH}") #todo add &status=success
TARGET_PIPELINE_ID=$(echo "$TARGET_PIPELINE_JSON" | jq -r '.[0].id' 2>/dev/null)
- |
# Handle missing pipeline data
if [ -z "$TARGET_PIPELINE_ID" ] || [ "$TARGET_PIPELINE_ID" = "null" ]; then
echo "No previous coverage data found. Skipping check.";
exit 0;
fi
- |
# Fetch coverage from the target branch's last successful pipeline
TARGET_JOBS_JSON=$(curl -s --header "PRIVATE-TOKEN: $CHECK_COVERAGE_TOKEN" "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/pipelines/${TARGET_PIPELINE_ID}/jobs")
TARGET_COVERAGE=$(echo "$TARGET_JOBS_JSON" | jq --arg JOB_NAME "$JOB_NAME" '.[] | select(.name==$JOB_NAME) | .coverage' | tr -d '"')
echo "target coverage: $TARGET_COVERAGE"
- |
# Fetch current coverage from this pipeline
CURRENT_JOBS_JSON=$(curl -s --header "PRIVATE-TOKEN: $CHECK_COVERAGE_TOKEN" "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/pipelines/${CI_PIPELINE_ID}/jobs")
CURRENT_COVERAGE=$(echo "$CURRENT_JOBS_JSON" | jq --arg JOB_NAME "$JOB_NAME" '.[] | select(.name==$JOB_NAME) | .coverage' | tr -d '"')
echo "current coverage: $CURRENT_COVERAGE"
# Validate if coverage values are available
- |
if [ -z "$TARGET_COVERAGE" ]; then
echo "No previous coverage data found. Skipping check.";
exit 0;
fi
- |
if [ -z "$CURRENT_COVERAGE" ]; then
echo "Failed to retrieve current coverage data.";
exit 1;
fi
- |
# Convert to numeric but preserve decimals
TARGET_COVERAGE_INT=$(echo "$TARGET_COVERAGE" | awk '{print int($1)}')
CURRENT_COVERAGE_INT=$(echo "$CURRENT_COVERAGE" | awk '{print int($1)}')
# Use bc for floating point comparison (will keep decimal precision)
TARGET_COVERAGE_FLOAT=$(echo "$TARGET_COVERAGE" | sed 's/%//')
CURRENT_COVERAGE_FLOAT=$(echo "$CURRENT_COVERAGE" | sed 's/%//')
# Compare with decimals if both values are below 1%
if (( $(echo "$TARGET_COVERAGE_FLOAT < 1" | bc -l) )) && (( $(echo "$CURRENT_COVERAGE_FLOAT < 1" | bc -l) )); then
if (( $(echo "$CURRENT_COVERAGE_FLOAT < $TARGET_COVERAGE_FLOAT" | bc -l) )); then
echo "Coverage decreased from ${TARGET_COVERAGE}% to ${CURRENT_COVERAGE}%! Merge request blocked.";
exit 1;
else
echo "Coverage check passed: ${CURRENT_COVERAGE}% (previous: ${TARGET_COVERAGE}%)";
fi
else
# Use integer comparison for values >= 1%
if [ "$CURRENT_COVERAGE_INT" -lt "$TARGET_COVERAGE_INT" ]; then
echo "Coverage decreased from ${TARGET_COVERAGE}% to ${CURRENT_COVERAGE}%! Merge request blocked.";
exit 1;
else
echo "Coverage check passed: ${CURRENT_COVERAGE}% (previous: ${TARGET_COVERAGE}%)";
fi
fi
Пример violation

Deptrac
Этот инструмент проверяет архитектурные правила, которые вы задаете. Например, что миграции не должны зависеть от кода (чтоб обеспечить их иммутабельность)
Создаем много конфигурационных файлов под каждый вид проверок: например для директорий в приложении, для модулей в src,
для package-by-feature в модулях и тп, не стоит все пихать в один файл
deptrac:
variables:
GIT_STRATEGY: none
stage: test
image: $DEV_IMAGE
script:
- vendor/bin/deptrac --config-file=deptrac.modules.yaml --cache-file=var/.deptrac.modules.cache
- vendor/bin/deptrac --config-file=deptrac.directories.yaml --cache-file=var/.deptrac.directories.cache
Пример конфига
deptrac.directories.yaml
deptrac:
analyser:
types:
- class
- class_superglobal
- file
- function
- function_call
- function_superglobal
- use
paths:
- bin
- config
- migrations
- public
- src
- tests
layers:
- { name: bin, collectors: [ { type: directory, value: ./bin/.* } ] }
- { name: migrations, collectors: [ { type: directory, value: ./migrations/.* } ] }
- { name: public, collectors: [ { type: directory, value: ./public/.* } ] }
- { name: src, collectors: [ { type: directory, value: ./src/.* } ] }
- { name: tests, collectors: [ { type: directory, value: ./tests/.* } ] }
ruleset:
bin: [src]
migrations:
public: [src]
tests: [src]
Пример violation

Migration rollback
В процессе разработки мы как правило только накатываем миграции, но не проверяем, что они могут откатываться. Однако если деплой пойдет не по плану, важно чтоб мы могли откатится. Поэтому существует такая джоба.
migrations_rollback_test:
stage: test
image: $DEV_IMAGE
services:
- name: postgres:14
alias: postgres
variables:
APP_ENV: test
DATABASE_URL: "pgsql://postgres:postgres@postgres:5432/test_db"
POSTGRES_DB: test_db
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
GIT_STRATEGY: none
before_script:
- apt-get update && apt-get install -y postgresql-client
- until pg_isready -h postgres -p 5432 -U postgres; do sleep 1; done
- bin/console doctrine:database:create --if-not-exists --env=test
- bin/console doctrine:migrations:migrate --no-interaction --env=test
script:
- bin/console doctrine:migrations:migrate first --no-interaction --env=test
KIKC
https://github.com/Checkmarx/kics
Расшифрую аббревиатуры:
KICS — Keeping Infrastructure as Code Secure
IAC- Infrastructure as a code.
Эта джоба ответственна за нахождение ваших docker/ansible/terraform/k8s файлов и проверку их на уязвимости, bad practices и просто ошибки
kics-iac-scan:
stage: test
image:
name: checkmarx/kics:latest
entrypoint: [""]
script:
- kics scan --no-progress -p ${PWD} -o ${PWD} --report-formats json --output-name kics-results
artifacts:
when: always
name: kics-results.json
paths:
- kics-results.json
Пример violation
{
"id": "f1a0bb482c0f478d4b6592a51da84de5f42cb34b4e185a46baad7c622ffa96f4",
"category": "sast",
"name": "Missing User Instruction",
"description": "A user should be specified in the dockerfile, otherwise the image will run as root",
"cve": "kics_id:fd54f200-402c-4333-a5a4-36ef6709af2f:2:0",
"severity": "Critical",
"scanner": {
"id": "kics",
"name": "kics"
},
"location": {
"file": "Docker/Dockerfile",
"start_line": 2
},
"identifiers": [
{
"type": "kics_id",
"name": "Missing User Instruction",
"value": "fd54f200-402c-4333-a5a4-36ef6709af2f",
"url": "https://docs.docker.com/engine/reference/builder/#user"
}
]
}
#
Composer outdated
Если патчи и минорные версии довольно часто обновляются в проекте, то переход на мажорные версии случаются редко.
С этой целью мы можем написать вот такую джобу, которая запускатеся по расписанию, допустим раз в 2 недели
С настройкой самой команды стоит поиграться, так как ваша компания может требовать нахождения на LTS версиях фреймворка например, тогда вы можете добавить --ignore
флаг для пакетов symfony (или любых других где требуется LTS).
composer_outdated_check:
stage: test
image: composer:latest
variables:
GIT_STRATEGY: none
script:
- composer outdated --strict --major-only --sort-by-age
rules:
- if: '$CI_PIPELINE_SOURCE == "schedule"'
Пример violation

PHPMD
https://github.com/phpmd/phpmd
Анализатор кода, во многом ошибки находит те же что и psalm / rector, мне больше всего нравится его способность указывать на нарушение SRP,
в виде boolean flags, Npath complexity, cyclomatic complexity.
Я всегда пользовался дефолтным конфигом, но лучше уж его настроить только на те правила, что не закрываются другими инструментами и имеют смысл.
Например есть правило ругающееся на слишком короткие названия переменных, что логично, но как итог у вас появляются сотни violations о переменных $id.
phpmd:
stage: test
image: $DEV_IMAGE
variables:
GIT_STRATEGY: none
script:
- vendor/bin/phpmd src json phpmd.xml --reportfile phpmd_result.json
artifacts:
when: always
paths:
- phpmd_result.json
Comments density
https://github.com/savinmikhail/Comments-Density
Использую этот инструмент, чтобы не возникал техдолг через todo, fixme комментарии. Можно однако пойти другим путем и через плагин автоматически создавать тикеты в багтрекере
comments_density:
stage: test
image: $DEV_IMAGE
variables:
GIT_STRATEGY: none
script:
- vendor/bin/comments_density analyze
Пример конфига
comments_density.php
<?php
declare(strict_types=1);
use SavinMikhail\CommentsDensity\AnalyzeComments\Comments\DocBlockComment;
use SavinMikhail\CommentsDensity\AnalyzeComments\Comments\FixMeComment;
use SavinMikhail\CommentsDensity\AnalyzeComments\Comments\LicenseComment;
use SavinMikhail\CommentsDensity\AnalyzeComments\Comments\MissingDocBlock;
use SavinMikhail\CommentsDensity\AnalyzeComments\Comments\RegularComment;
use SavinMikhail\CommentsDensity\AnalyzeComments\Comments\TodoComment;
use SavinMikhail\CommentsDensity\AnalyzeComments\Config\DTO\Config;
return new Config(
directories: [
'src',
],
thresholds: [
TodoComment::NAME => 0,
FixMeComment::NAME => 0,
],
cacheDir: 'var/comments-density',
disable: [
DocBlockComment::NAME,
RegularComment::NAME,
LicenseComment::NAME,
MissingDocBlock::NAME,
]
);
Пример violation

Rector
https://github.com/rectorphp/rector
Вообще это инструмент автоматического рефакторинга. Супер полезен при миграции на новую версию php или фреймворка, но имеет и правила для «повседневной» разработки.
Как и php-cs-fixer автоматически фиксит ошибки
rector:
variables:
GIT_STRATEGY: none
stage: test
image: $DEV_IMAGE
script:
- vendor/bin/rector --dry-run
Пример конфига
rector.php
<?php
declare(strict_types=1);
use Rector\Config\RectorConfig;
use Rector\Php80\Rector\Class_\StringableForToStringRector;
use Rector\Php83\Rector\ClassMethod\AddOverrideAttributeToOverriddenMethodsRector;
use SavinMikhail\AddNamedArgumentsRector\AddNamedArgumentsRector;
return RectorConfig::configure()
->withPaths([
__DIR__ . '/bin/console',
__DIR__ . '/config',
__DIR__ . '/public',
__DIR__ . '/src',
__DIR__ . '/tests',
])
->withParallel()
->withCache(__DIR__ . '/var/rector')
->withPhpSets(php82: true)
->withRules([
AddNamedArgumentsRector::class,
])
->withSkip([
StringableForToStringRector::class,
AddOverrideAttributeToOverriddenMethodsRector::class,
]);
Пример violation

Trivy
https://github.com/aquasecurity/trivy
Сканирует ваш docker image и находит уязвимости. По моему опыту это как правило уязвимости в установленных библиотеках
trivy_container_scan:
image:
name: docker.io/aquasec/trivy:latest
entrypoint: [""]
variables:
GIT_STRATEGY: none
TRIVY_USERNAME: "$CI_REGISTRY_USER"
TRIVY_PASSWORD: "$CI_REGISTRY_PASSWORD"
TRIVY_AUTH_URL: "$CI_REGISTRY"
TRIVY_NO_PROGRESS: "true"
TRIVY_CACHE_DIR: ".trivycache/"
FULL_IMAGE_NAME: $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG
script:
- trivy --version
- time trivy image --download-db-only
- time trivy image --exit-code 0 --format template --template "@/contrib/gitlab.tpl"
--output "$CI_PROJECT_DIR/gl-container-scanning-report.json" "$FULL_IMAGE_NAME"
- time trivy image --exit-code 0 "$FULL_IMAGE_NAME"
- time trivy image --exit-code 1 --severity CRITICAL "$FULL_IMAGE_NAME"
cache:
paths:
- .trivycache/
artifacts:
when: always
name: gl-container-scanning-report.json
paths:
- gl-container-scanning-report.json
reports:
container_scanning: gl-container-scanning-report.json
stage: test
Пример violation
{
"id": "024fd5bd42b3cfed92af89216a3c074c97c20b35",
"severity": "High",
"location": {
"dependency": {
"package": {
"name": "libxml2"
},
"version": "2.9.14+dfsg-1.3~deb12u1"
},
"operating_system": "debian 12.9",
"image": "registry.gitlab.com/tsyren-dashidymbrylov/online-shop:master"
},
"identifiers": [
{
"type": "cve",
"name": "CVE-2024-25062",
"value": "CVE-2024-25062",
"url": "https://access.redhat.com/errata/RHSA-2024:2679"
}
],
"links": [
{
"url": "https://access.redhat.com/errata/RHSA-2024:2679"
},
{
"url": "https://access.redhat.com/security/cve/CVE-2024-25062"
},
{
"url": "https://bugzilla.redhat.com/2262726"
},
{
"url": "https://bugzilla.redhat.com/show_bug.cgi?id=2262726"
},
{
"url": "https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2024-25062"
},
{
"url": "https://errata.almalinux.org/9/ALSA-2024-2679.html"
},
{
"url": "https://errata.rockylinux.org/RLSA-2024:3626"
},
{
"url": "https://gitlab.gnome.org/GNOME/libxml2/-/issues/604"
},
{
"url": "https://gitlab.gnome.org/GNOME/libxml2/-/tags"
},
{
"url": "https://linux.oracle.com/cve/CVE-2024-25062.html"
},
{
"url": "https://linux.oracle.com/errata/ELSA-2024-3626.html"
},
{
"url": "https://nvd.nist.gov/vuln/detail/CVE-2024-25062"
},
{
"url": "https://ubuntu.com/security/notices/USN-6658-1"
},
{
"url": "https://ubuntu.com/security/notices/USN-6658-2"
},
{
"url": "https://www.cve.org/CVERecord?id=CVE-2024-25062"
}
],
"details": {
"vulnerable_package": {
"name": "Vulnerable Package",
"type": "text",
"value": "libxml2:2.9.14+dfsg-1.3~deb12u1"
},
"vendor_status": {
"name": "Vendor Status",
"type": "text",
"value": "affected"
}
},
"description": "An issue was discovered in libxml2 before 2.11.7 and 2.12.x before 2.12.5. When using the XML Reader interface with DTD validation and XInclude expansion enabled, processing crafted XML documents can lead to an xmlValidatePopElement use-after-free.",
"solution": "No solution provided"
}
```</details>
Gitleaks
https://github.com/gitleaks/gitleaks?tab=readme-ov-file#docker
Следит за тем, чтоб ни в истории коммитов, ни в самих файлах не было ключей, паролей, токенов и тому подобного.
gitleaks_secret_detection:
stage: test
image:
name: zricethezav/gitleaks:latest
entrypoint: [""]
script:
- gitleaks dir . --report-path gitleaks-report.json
artifacts:
when: always
paths:
- gitleaks-report.json
Пример violation
{
"id": "f24458cd78b17036e70038cb3386ac1b6d1d985160a5101d528197a2c114ce4a",
"category": "secret_detection",
"name": "Password in URL",
"description": "Password in URL\n\nFor general guidance on handling security incidents with regards to leaked keys, please see the GitLab documentation on\n[Credential exposure to the internet](https://docs.gitlab.com/ee/security/responding_to_security_incidents.html#credential-exposure-to-public-internet).",
"cve": ".env:a013fea9cebb7f3c805ca0c7d1ec17bac4bbe3c18079eb0b45f96a0ce11f18b6:Password in URL",
"severity": "Critical",
"confidence": "Unknown",
"raw_source_code_extract": "amqp://guest:guest@localhost:5672/%2f/messages",
"scanner": {
"id": "gitleaks",
"name": "Gitleaks"
},
"location": {
"file": ".env",
"commit": {
"author": "Tsyren Dashidymbrylov",
"date": "2025-02-16T04:27:25Z",
"message": "Update .gitlab-ci.yml file",
"sha": "6e36ee79de5fe9405e5cbac0dedbcff530d72363"
},
"start_line": 34
},
"identifiers": [
{
"type": "gitleaks_rule_id",
"name": "Gitleaks rule ID Password in URL",
"value": "Password in URL"
}
]
}
Deploy
Аналогично сборке приложения, имеем две джобы для деплоя, в целом одинаковых
Деплой на dev стенд описан через docker, на prod — через bare metal, просто для примера (лучше деплоить image, зря мы что ли собирали образ в build stage?)
Если у вас k8s, то что я вам тут вообще рассказываю 🙂
deploy_dev:
stage: deploy
when: manual
before_script:
- eval $(ssh-agent -s)
- ssh-add <(echo "$SSH_PRIVATE_KEY") # Load SSH key
script:
- echo "🚀 Deploying Dev Environment on Remote Server..."
- |
ssh -o StrictHostKeyChecking=no $DEV_USER@$DEV_HOST << 'EOF'
set -e # Stop if any command fails
echo "🔄 Pulling latest Docker image..."
docker login -u gitlab-ci-token -p $CI_JOB_TOKEN $CI_REGISTRY
docker pull $DEV_IMAGE
echo "🛑 Stopping existing container..."
docker rename myapp-dev myapp-dev-backup || true # Keep backup
docker stop myapp-dev-backup || true
docker rm myapp-dev-backup || true
echo "🚀 Starting new container..."
docker run -d --name myapp-dev -p 8080:80 $DEV_IMAGE
echo "⏳ Waiting for container to start..."
sleep 5 # Ensure the container is running before executing commands
echo "🔄 Running database migrations (All-or-Nothing)..."
if docker exec myapp-dev bin/console doctrine:migrations:migrate --no-interaction --all-or-nothing; then
echo "✅ Migrations successful!"
docker rm myapp-dev-backup || true # Remove backup since new deployment works
else
echo "❌ Migration failed! Rolling back..."
docker stop myapp-dev || true
docker rm myapp-dev || true
docker rename myapp-dev-backup myapp-dev # Restore previous version
docker start myapp-dev
echo "🔄 Rolled back to previous version."
exit 1 # Fail the deployment
fi
echo "✅ Dev deployment complete!"
EOF
needs: [build_dev_image]
deploy_prod:
stage: deploy
when: manual
only:
- tags
before_script:
- eval $(ssh-agent -s)
- ssh-add <(echo "$SSH_PRIVATE_KEY")
script:
- echo "🚀 Deploying Production on Bare Metal Server..."
- |
ssh -o StrictHostKeyChecking=no $PROD_USER@$PROD_HOST << 'EOF'
set -e
echo "🔄 Checking out tag $CI_COMMIT_TAG..."
cd $PATH_TO_PROJECT
git fetch --tags origin
git checkout $CI_COMMIT_TAG
echo "⚙️ Installing dependencies..."
composer install --no-dev --optimize-autoloader
echo "🧹 Clearing cache..."
bin/console cache:clear
echo "🔄 Running database migrations..."
bin/console doctrine:migrations:migrate --no-interaction --all-or-nothing
echo "✅ Production deployment complete!"
EOF
needs: [build_prod_image]
DAST
DAST — Dynamic Application Security Testing — на этом этапе инструменты будут сканировать ваше задеплоенное приложение на предмет раскрытых конфигов,
sql injections, незащищенного соединения, портов и тп.
Есть готовые шаблоны для DAST, но они приспособлены для Ultimate подписки, поэтому переписаны
Nuclei
https://github.com/projectdiscovery/nuclei
По умолчанию ипользует около 8 тысяч различных темплейтов сканирования, из-за чего может относительно долго работать (у меня ушло минут 5 до получения репорта)
nuclei:
stage: DAST
image: golang:latest
variables:
TARGET_URL: https://your-app/
before_script:
- go install -v github.com/projectdiscovery/nuclei/v2/cmd/nuclei@latest
- curl -I $TARGET_URL || echo "Target is unreachable"
script:
- nuclei -u $TARGET_URL -jsonl nuclei-report.jsonl || true
artifacts:
when: always
paths:
- nuclei-report.jsonl
Пример violation
Вот здесь nuclei нашла вебстраницу с конфигами моего php, публично доступную, и присвоила низкий уровень опасности
{
"info": {
"name": "PHPinfo Page - Detect",
"author": [
"pdteam",
"daffainfo",
"meme-lord",
"dhiyaneshdk",
"wabafet",
"mastercho"
],
"tags": [
"config",
"exposure",
"phpinfo"
],
"description": "PHPinfo page was detected. The output of the phpinfo() command can reveal sensitive and detailed PHP environment information.\n",
"severity": "low",
"metadata": {
"max-request": 25
},
"classification": {
"cve-id": null,
"cwe-id": [
"cwe-200"
]
},
"remediation": "Remove PHP Info pages from publicly accessible sites, or restrict access to authorized users only."
}
}
Полный пример
В итоге мы получили примерно следующую картину:

Полный файл:
.gitlab-ci.yml
stages:
- build
- test
- deploy
- DAST
variables:
DEV_IMAGE: $CI_REGISTRY_IMAGE:dev
PROD_IMAGE: $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG
build_dev_dependencies:
stage: build
image: composer:latest
before_script:
- echo "$DEV_ENV_FILE" > .env.local
script:
- composer install --no-interaction
artifacts:
paths:
- vendor/
- .
build_dev_image:
services:
- name: docker:dind
alias: dind
image: docker:20.10.16
stage: build
variables:
GIT_STRATEGY: none
before_script:
- docker login -u gitlab-ci-token -p $CI_JOB_TOKEN $CI_REGISTRY
script:
- docker build -t $DEV_IMAGE ./.docker/dev
- docker push $DEV_IMAGE
needs: [build_dev_dependencies]
build_prod_dependencies:
stage: build
image: composer:latest
variables:
APP_ENV: prod
APP_DEBUG: 0
before_script:
- echo "$PROD_ENV_FILE" > .env.local
script:
- composer install --no-dev --optimize-autoloader --no-interaction
- composer dump-env prod
artifacts:
paths:
- vendor/
- .
only:
- tags
build_prod_image:
services:
- name: docker:dind
alias: dind
image: docker:20.10.16
stage: build
variables:
GIT_STRATEGY: none
before_script:
- docker login -u gitlab-ci-token -p $CI_JOB_TOKEN $CI_REGISTRY
script:
- docker build -t $PROD_IMAGE ./.docker/prod
- docker push $PROD_IMAGE
- docker push $CI_REGISTRY_IMAGE:latest
needs: [build_prod_dependencies]
only:
- tags
cs:
stage: test
image: $DEV_IMAGE
variables:
GIT_STRATEGY: none
script:
- ./vendor/bin/php-cs-fixer -v --config=.php-cs-fixer.dist.php fix --dry-run --stop-on-violation --diff
phpunit:
stage: test
image: $DEV_IMAGE
services:
- name: postgres:14
alias: postgres
variables:
APP_ENV: test
DATABASE_URL: "pgsql://postgres:postgres@postgres:5432/test_db"
POSTGRES_DB: test_db
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
GIT_STRATEGY: none
before_script:
- apt-get update && apt-get install -y postgresql-client
- until pg_isready -h postgres -p 5432 -U postgres; do sleep 1; done
- bin/console doctrine:database:create --if-not-exists
- bin/console doctrine:migrations:migrate --no-interaction
script:
- XDEBUG_MODE=coverage php ./vendor/bin/phpunit --colors=never --coverage-text --coverage-cobertura=coverage.cobertura.xml --log-junit phpunit-report.xml --do-not-cache-result
coverage: '/^\s*Lines:\s*\d+.\d+\%/'
artifacts:
when: always
reports:
junit: phpunit-report.xml
coverage_report:
coverage_format: cobertura
path: coverage.cobertura.xml
composer:
variables:
GIT_STRATEGY: none
stage: test
image: $DEV_IMAGE
script:
- composer normalize --diff --dry-run
- composer validate
- vendor/bin/composer-require-checker check --config-file=composer-require-checker.json
- php8.2 vendor/bin/composer-unused
- composer audit
- composer check-platform-reqs
psalm:
variables:
GIT_STRATEGY: none
stage: test
image: $DEV_IMAGE
script:
- vendor/bin/psalm
di_lint:
variables:
GIT_STRATEGY: none
stage: test
image: $DEV_IMAGE
script:
- bin/console cache:clear --env=prod
- bin/console lint:container --env=prod
schema_validate:
variables:
GIT_STRATEGY: none
stage: test
image: $DEV_IMAGE
script:
- bin/console doctrine:schema:validate --skip-sync
check_coverage:
image: alpine:latest
stage: test
needs: [phpunit]
variables:
JOB_NAME: phpunit
TARGET_BRANCH: master
GIT_STRATEGY: none
before_script:
- apk add --update --no-cache curl jq
rules:
- if: '$CI_COMMIT_BRANCH != $TARGET_BRANCH'
script:
- |
# Get latest pipeline ID from the target branch using the PAT
TARGET_PIPELINE_JSON=$(curl -s --header "PRIVATE-TOKEN: $CHECK_COVERAGE_TOKEN" "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/pipelines?ref=${TARGET_BRANCH}") #todo add &status=success
TARGET_PIPELINE_ID=$(echo "$TARGET_PIPELINE_JSON" | jq -r '.[0].id' 2>/dev/null)
- |
# Handle missing pipeline data
if [ -z "$TARGET_PIPELINE_ID" ] || [ "$TARGET_PIPELINE_ID" = "null" ]; then
echo "No previous coverage data found. Skipping check.";
exit 0;
fi
- |
# Fetch coverage from the target branch's last successful pipeline
TARGET_JOBS_JSON=$(curl -s --header "PRIVATE-TOKEN: $CHECK_COVERAGE_TOKEN" "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/pipelines/${TARGET_PIPELINE_ID}/jobs")
TARGET_COVERAGE=$(echo "$TARGET_JOBS_JSON" | jq --arg JOB_NAME "$JOB_NAME" '.[] | select(.name==$JOB_NAME) | .coverage' | tr -d '"')
echo "target coverage: $TARGET_COVERAGE"
- |
# Fetch current coverage from this pipeline
CURRENT_JOBS_JSON=$(curl -s --header "PRIVATE-TOKEN: $CHECK_COVERAGE_TOKEN" "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/pipelines/${CI_PIPELINE_ID}/jobs")
CURRENT_COVERAGE=$(echo "$CURRENT_JOBS_JSON" | jq --arg JOB_NAME "$JOB_NAME" '.[] | select(.name==$JOB_NAME) | .coverage' | tr -d '"')
echo "current coverage: $CURRENT_COVERAGE"
# Validate if coverage values are available
- |
if [ -z "$TARGET_COVERAGE" ]; then
echo "No previous coverage data found. Skipping check.";
exit 0;
fi
- |
if [ -z "$CURRENT_COVERAGE" ]; then
echo "Failed to retrieve current coverage data.";
exit 1;
fi
- |
# Convert to numeric but preserve decimals
TARGET_COVERAGE_INT=$(echo "$TARGET_COVERAGE" | awk '{print int($1)}')
CURRENT_COVERAGE_INT=$(echo "$CURRENT_COVERAGE" | awk '{print int($1)}')
# Use bc for floating point comparison (will keep decimal precision)
TARGET_COVERAGE_FLOAT=$(echo "$TARGET_COVERAGE" | sed 's/%//')
CURRENT_COVERAGE_FLOAT=$(echo "$CURRENT_COVERAGE" | sed 's/%//')
# Compare with decimals if both values are below 1%
if (( $(echo "$TARGET_COVERAGE_FLOAT < 1" | bc -l) )) && (( $(echo "$CURRENT_COVERAGE_FLOAT < 1" | bc -l) )); then
if (( $(echo "$CURRENT_COVERAGE_FLOAT < $TARGET_COVERAGE_FLOAT" | bc -l) )); then
echo "Coverage decreased from ${TARGET_COVERAGE}% to ${CURRENT_COVERAGE}%! Merge request blocked.";
exit 1;
else
echo "Coverage check passed: ${CURRENT_COVERAGE}% (previous: ${TARGET_COVERAGE}%)";
fi
else
# Use integer comparison for values >= 1%
if [ "$CURRENT_COVERAGE_INT" -lt "$TARGET_COVERAGE_INT" ]; then
echo "Coverage decreased from ${TARGET_COVERAGE}% to ${CURRENT_COVERAGE}%! Merge request blocked.";
exit 1;
else
echo "Coverage check passed: ${CURRENT_COVERAGE}% (previous: ${TARGET_COVERAGE}%)";
fi
fi
deptrac:
variables:
GIT_STRATEGY: none
stage: test
image: $DEV_IMAGE
script:
- vendor/bin/deptrac --config-file=deptrac.modules.yaml --cache-file=var/.deptrac.modules.cache
- vendor/bin/deptrac --config-file=deptrac.directories.yaml --cache-file=var/.deptrac.directories.cache
migrations_rollback_test:
stage: test
image: $DEV_IMAGE
services:
- name: postgres:14
alias: postgres
variables:
APP_ENV: test
DATABASE_URL: "pgsql://postgres:postgres@postgres:5432/test_db"
POSTGRES_DB: test_db
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
GIT_STRATEGY: none
before_script:
- apt-get update && apt-get install -y postgresql-client
- until pg_isready -h postgres -p 5432 -U postgres; do sleep 1; done
- bin/console doctrine:database:create --if-not-exists --env=test
- bin/console doctrine:migrations:migrate --no-interaction --env=test
script:
- bin/console doctrine:migrations:migrate first --no-interaction --env=test
kics-iac-scan:
stage: test
image:
name: checkmarx/kics:latest
entrypoint: [""]
script:
- kics scan --no-progress -p ${PWD} -o ${PWD} --report-formats json --output-name kics-results
artifacts:
when: always
name: kics-results.json
paths:
- kics-results.json
composer_outdated_check:
stage: test
image: composer:latest
variables:
GIT_STRATEGY: none
script:
- composer outdated --strict --major-only --sort-by-age
rules:
- if: '$CI_PIPELINE_SOURCE == "schedule"'
phpmd:
stage: test
image: $DEV_IMAGE
variables:
GIT_STRATEGY: none
script:
- vendor/bin/phpmd src json phpmd.xml --reportfile phpmd_result.json
artifacts:
when: always
paths:
- phpmd_result.json
comments_density:
stage: test
image: $DEV_IMAGE
variables:
GIT_STRATEGY: none
script:
- vendor/bin/comments_density analyze
rector:
variables:
GIT_STRATEGY: none
stage: test
image: $DEV_IMAGE
script:
- vendor/bin/rector --dry-run
trivy_container_scan:
image:
name: docker.io/aquasec/trivy:latest
entrypoint: [""]
variables:
GIT_STRATEGY: none
TRIVY_USERNAME: "$CI_REGISTRY_USER"
TRIVY_PASSWORD: "$CI_REGISTRY_PASSWORD"
TRIVY_AUTH_URL: "$CI_REGISTRY"
TRIVY_NO_PROGRESS: "true"
TRIVY_CACHE_DIR: ".trivycache/"
FULL_IMAGE_NAME: $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG
script:
- trivy --version
- time trivy image --download-db-only
- time trivy image --exit-code 0 --format template --template "@/contrib/gitlab.tpl"
--output "$CI_PROJECT_DIR/gl-container-scanning-report.json" "$FULL_IMAGE_NAME"
- time trivy image --exit-code 0 "$FULL_IMAGE_NAME"
- time trivy image --exit-code 1 --severity CRITICAL "$FULL_IMAGE_NAME"
cache:
paths:
- .trivycache/
artifacts:
when: always
name: gl-container-scanning-report.json
paths:
- gl-container-scanning-report.json
reports:
container_scanning: gl-container-scanning-report.json
stage: test
gitleaks_secret_detection:
stage: test
image:
name: zricethezav/gitleaks:latest
entrypoint: [""]
script:
- gitleaks dir . --report-path gitleaks-report.json
artifacts:
when: always
paths:
- gitleaks-report.json
deploy_dev:
stage: deploy
when: manual
before_script:
- eval $(ssh-agent -s)
- ssh-add <(echo "$SSH_PRIVATE_KEY") # Load SSH key
script:
- echo "🚀 Deploying Dev Environment on Remote Server..."
- |
ssh -o StrictHostKeyChecking=no $DEV_USER@$DEV_HOST << 'EOF'
set -e # Stop if any command fails
echo "🔄 Pulling latest Docker image..."
docker login -u gitlab-ci-token -p $CI_JOB_TOKEN $CI_REGISTRY
docker pull $DEV_IMAGE
echo "🛑 Stopping existing container..."
docker rename myapp-dev myapp-dev-backup || true # Keep backup
docker stop myapp-dev-backup || true
docker rm myapp-dev-backup || true
echo "🚀 Starting new container..."
docker run -d --name myapp-dev -p 8080:80 $DEV_IMAGE
echo "⏳ Waiting for container to start..."
sleep 5 # Ensure the container is running before executing commands
echo "🔄 Running database migrations (All-or-Nothing)..."
if docker exec myapp-dev bin/console doctrine:migrations:migrate --no-interaction --all-or-nothing; then
echo "✅ Migrations successful!"
docker rm myapp-dev-backup || true # Remove backup since new deployment works
else
echo "❌ Migration failed! Rolling back..."
docker stop myapp-dev || true
docker rm myapp-dev || true
docker rename myapp-dev-backup myapp-dev # Restore previous version
docker start myapp-dev
echo "🔄 Rolled back to previous version."
exit 1 # Fail the deployment
fi
echo "✅ Dev deployment complete!"
EOF
needs: [build_dev_image]
deploy_prod:
stage: deploy
when: manual
only:
- tags
before_script:
- eval $(ssh-agent -s)
- ssh-add <(echo "$SSH_PRIVATE_KEY")
script:
- echo "🚀 Deploying Production on Bare Metal Server..."
- |
ssh -o StrictHostKeyChecking=no $PROD_USER@$PROD_HOST << 'EOF'
set -e
echo "🔄 Checking out tag $CI_COMMIT_TAG..."
cd $PATH_TO_PROJECT
git fetch --tags origin
git checkout $CI_COMMIT_TAG
echo "⚙️ Installing dependencies..."
composer install --no-dev --optimize-autoloader
echo "🧹 Clearing cache..."
bin/console cache:clear
echo "🔄 Running database migrations..."
bin/console doctrine:migrations:migrate --no-interaction --all-or-nothing
echo "✅ Production deployment complete!"
EOF
needs: [build_prod_image]
nuclei:
stage: DAST
image: golang:latest
variables:
TARGET_URL: https://your-app/
before_script:
- go install -v github.com/projectdiscovery/nuclei/v2/cmd/nuclei@latest
- curl -I $TARGET_URL || echo "Target is unreachable"
script:
- nuclei -u $TARGET_URL -jsonl nuclei-report.jsonl || true
artifacts:
when: always
paths:
- nuclei-report.jsonl
Заключение
Помните, внедрить хоть все инструменты в самом начале жизненного цикла проекта легче, чем даже 1 спустя год его жизни и развития
Конечно, сначала эти все проверки качества здорово замедляют, но когда проект вырастает, количество людей в команде увеличивается с 1 тебя до 5 бэков, такой контроль качества здорово уменьшает скорость роста техдолга и количество говнокода
Понравилась статья? Подписывайтесь на мой тг канал о разработке
Использованные источники
-
GitLab template examples: https://docs.gitlab.com/ci/examples/#cicd-templates
-
GitLab template usage documentation: https://docs.gitlab.com/ci/yaml/includes/#include-a-single-configuration-file
-
GitLab application security documentation: https://docs.gitlab.com/user/application_security/
-
GitLab DevSecOps tutorial: https://gitlab-da.gitlab.io/tutorials/security-and-governance/devsecops/simply-vulnerable-notes/
-
GitLab security scanner integration documentation: https://docs.gitlab.com/user/application_security/#security-scanning-without-auto-devops
-
GitLab security and governance solutions: https://about.gitlab.com/solutions/security-compliance/
-
GitLab DevSecOps demo application: https://gitlab.com/gitlab-da/tutorials/security-and-governance/devsecops/simply-vulnerable-notes
-
PHP template https://gitlab.com/gitlab-org/gitlab/-/blob/master/lib/gitlab/ci/templates/PHP.gitlab-ci.yml
-
Restrict test coverage decrease: https://rpadovani.com/gitlab-code-coverage#the-gitlab-pipeline-job
-
Adding PHPUnit Test Log and Coverage to GitLab CI/CD Pipeline https://dev.to/muhamadhhassan/adding-phpunit-test-log-and-coverage-to-gitlab-cicd-33b5
-
Unit test reports https://docs.gitlab.com/ci/testing/unit_test_reports/
Источник: https://habr.com/ru/articles/889120/