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

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

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

Содержание

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

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

    • Build

    • Test

    • Deploy

    • DAST

  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 "						
Источник: https://habr.com/ru/articles/889120/


Интересные статьи

Интересные статьи

10 лет назад мы начинали бизнес студии с разработки сайтов на CMS 1С-Битрикс. Сегодня наш основной стек связан с подходом Single Page Application на Symfony и Nuxt, но клиенты по-прежнему просят сайты...
Разработчики часто встречаются с непредвиденными сложностями, причем иногда крайне непросто найти причину. На одном из проектов мы столкнулись с серьезной проблемой, которую не смогли сразу решить даж...
У невероятного роста Youtube есть одно последствие, радостное и грустное одновременно - множество историй потеряются под слоями новой краски. Именно поэтому я хочу рассказать одну из них - историю тог...
Привет, сегодня я расскажу вам о разработке своей первой игры на Android, публикации ее в Google Play, закупке рекламы в Google Ads и доходе. Я советую эту статью к прочт...
У некоторых бизнес-тренеров в области е-коммерса и консультантов по увеличению интернет-продаж на многие вопросы часто можно слышать универсальную отмазку — «надо тестировать» или другую (чтобы не...