Практическое руководство по настройке CI/CD для PHP проектов

В этом лонгриде я расскажу немного теории о CI/CD, но в основном это будут практические примеры и советы, в первую очередь полезные для PHP backend разработчиков, однако некоторые инструменты подходят и для других языков, и вы можете уловить общую идею, как писать пайплайны

Содержание

  1. Почему CI/CD полезен и для бизнеса и для разработки

  2. Пишем пайплайн

  3. Полный пример

  4. Заключение

  5. Использованные источники

Почему 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&amp;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 &lt;(echo "$SSH_PRIVATE_KEY")  # Load SSH key
   script:
      - echo "🚀 Deploying Dev Environment on Remote Server..."
      - |
         ssh -o StrictHostKeyChecking=no $DEV_USER@$DEV_HOST &lt;&lt; '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 &lt;(echo "$SSH_PRIVATE_KEY")
   script:
      - echo "🚀 Deploying Production on Bare Metal Server..."
      - |
         ssh -o StrictHostKeyChecking=no $PROD_USER@$PROD_HOST &lt;&lt; '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" &gt; .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" &gt; .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 &amp;&amp; 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 &amp;status=success
         TARGET_PIPELINE_ID=$(echo "$TARGET_PIPELINE_JSON" | jq -r '.[0].id' 2&gt;/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 &lt; 1" | bc -l) )) &amp;&amp; (( $(echo "$CURRENT_COVERAGE_FLOAT &lt; 1" | bc -l) )); then
           if (( $(echo "$CURRENT_COVERAGE_FLOAT &lt; $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 &gt;= 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 &amp;&amp; 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 &lt;(echo "$SSH_PRIVATE_KEY")  # Load SSH key
   script:
      - echo "🚀 Deploying Dev Environment on Remote Server..."
      - |
         ssh -o StrictHostKeyChecking=no $DEV_USER@$DEV_HOST &lt;&lt; '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 &lt;(echo "$SSH_PRIVATE_KEY")
   script:
      - echo "🚀 Deploying Production on Bare Metal Server..."
      - |
         ssh -o StrictHostKeyChecking=no $PROD_USER@$PROD_HOST &lt;&lt; '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 бэков, такой контроль качества здорово уменьшает скорость роста техдолга и количество говнокода

Понравилась статья? Подписывайтесь на мой тг канал о разработке

Использованные источники

Источник: https://habr.com/ru/articles/889120/

Опубликовано в категории: Статьи