Image

Drupal 8 et Gitlab-ci

Dans le cadre du travail en livraison continue, il existe de nombreuses solutions de CD. Voici un exemple d'utilisation de Gitlab-CI avec Drupal 8.

pipeline status coverage report

Intégration / Livraison continue

Le service d'intégration continue

Gitlab-CI fait parti des derniers arrivant et propose une solution complète, très extensible et pour l'instant sans coût pour mettre en place votre flux de travail avec Drupal 8.

Le principe est simple, à chaque itération (branche, commit ou tag...) vous pouvez lancer un certain nombre de tâches automatisés.

Ces tâches peuvent être du build (téléchargement externe de librairies si vous utilisez Drupal 8 avec Composer), de la verification de code (Standard de Php, standard de vos fichiers Javascript, css ou Sass...), de la vérification de sécurité (Dans votre code ou les dépendances de votre projet) et du déploiement sur vos serveurs de staging, pré-production, production.

Les tâches en intégration continue

En fait les tâches automatisés peuvent être de toute nature, du simple script au programme lancé depuis sa version dockerizé. Gitlab-CI vous fournit l'infrastructure (Serveur Web, Base de données, Serveur NodeJs...) ainsi que des "runners", c'est à dire des machines qui exécuterons vos tâches.

Grâce à Docker et à la virtualisation, pas besoin de mettre en place toute une machine avec les mises à jours et le téléchargement de programmes... Une simple image avec le ou les outils permet le lancement d'une tâche en temps record !

Intégration continue de Drupal 8 avec Gitlab-CI

Voilà pour la théorie, voyons voir ce que cela donne en pratique pour un site Drupal 8 avec ce template de Gitlab-CI avec Drupal 8.

J'ai choisi un flux de travail basique, une branche "testing" pour le build et les tests avec au final un déploiement sur un environnement de test :

Gitlab ci pipeline Drupal 8

Le principe est donc que chaque fois que l'on pousse un commit sur cette branche, tout se déclenche à la chaîne avec des alertes et la possibilité de stopper le processus.

La première partie build correspond à l'installation des fichiers de Drupal avec Composer. On ne place sur le repo que le code custom, la configuration et le template du site.

Ce build permet de tester que tout fonctionne bien, si il passe une deuxième étape permet de lancer tous types de tests pour Drupal 8.

Testing de votre code

Drupal 8 intègre 4 types de tests : Unit, Kernel, Functional, Functional Javascript permettant de tester les différentes parties de votre code. Ces tests utilisent tous Phpunit (Framework de test) et dans certains cas

Depuis Drupal 8.6 on se rapproche également des méthodes de développement frontend plus modernes avec l'intégration de NightwatchJS pour le test de votre code Javascript en direct (sans passer par des fichiers Php de test...)

La partie tests du flux ci dessus intègre également un Security report en utilisant le très pratique programme de Symfony.

Ainsi qu'un Code coverage, c'est une option de Phpunit pour déterminer la quantité de code Php qui possède un test et déterminer si vous couvrez bien la majorité de votre code. L'intérêt étant que Gitlab va être capable d'extraire cette information de vos test pour afficher un badge de résultat comme celui là Badge coverage

Qualité de code

A partir de cette étape, les tâches deviennent commune avec les autres branches, c'est un peu un minimum, voici un flux sur la branche master :

Exemple pipeline Gitlab CI drupal 8 master

Pour la vérification du code Php on utilisera Code sniffer avec les standards Drupal présents dans le module Coder.

Standards de code css et javascript

Pour la partie lint qui concerne js / css, on utilisera de nouveau les standards Drupal 8.6+ avec Stylelint et Eslint. Pour un projet avec un thème Bootstrap SAss, un petit bonus Sass basé sur des règles Bootstrap avec Sass lint.

Metriques, taille et statistiques

Enfin pour le fun on ajoutera quelque mesures du code avant de pouvoir déployer sur vos environnements. Phpmetrics, Phploc et Pdepend nous donnerons de magnifiques graphes et statistiques à joindre à votre projet !

Phpmetrics example de résultat.Pdepend example de résultat.

Livraison continue

Si tout se passe bien les dernières étapes s'occuperont de déployer votre code validé sur vos environnements, de façons automatisée ou manuelle.

Pour cette étape vous avez l'embarras du choix et ce projet ne traite pas vraiment de cette partie. L'exemple donné permet une connection ssh avec clef à votre serveur et ensuite lance un script. De façons basique ce script récupère le projet et devra lancer plusieurs commandes de mise à jour sur votre site (Composer, compass, drush updb, drush entup, drush config:import...).

Montrez moi du code !

Vous trouverez l'intégralité du code utilisé sur ce projet : https://gitlab.com/mog33/gitlab-ci-drupal

Vous trouverez toutes les informations pour mettre en place tout ça sur votre code, avec entre autre comme principal élément ce fichier de configuration.

################################################################################
# Gitlab CI samples for Drupal 8  project, code quality and deploy.
#
# Project: https://gitlab.com/mog33/gitlab-ci-drupal
# Documentation: https://gitlab.com/mog33/gitlab-ci-drupal
# Author: Jean Valverde contactatdev-drupal.com
# License: GPL-3
#
# This file is way too huge for a normal CI process, this is just a commented
# example of working jobs for Drupal 8, feel free to cherry pick what you need.
#
# For Gilab CI help on this file see:
#   https://docs.gitlab.com/ee/ci/yaml
#
################################################################################

variables:
  ##############################################################################
  # Common variables for all jobs, edit this part to your needs.
  #
  # Make CI more verbose in case of problem.
  # CI_DEBUG_TRACE: "1"
  #
  # Path is relative to project root, web/ is for Drupal Composer template,
  # change it if you are using docroot/ or any other web root.
  WEB_ROOT: "web"

  # See Phpqa available tools:
  #   https://github.com/EdgedesignCZ/phpqa#available-tools
  # Allow some errors, this will stop the pipeline if a limit is reached.
  TOOLS: "--tools phpcs:0,phpmd,phpcpd,parallel-lint"

  # Phpunit tests to run, only custom code or empty for all code.
  # see .gitlab-ci/phpunit.xml for settings.
  TESTS: "custom"

  ##############################################################################
  # Global settings for all env used for deploy.
  ##############################################################################
  # 
  # USER_NAME: "ubuntu"
  # DRUPAL_FOLDER: "/var/www/htdocs/MY_DRUPAL_ROOT"
  # For Scss support when build in case of a Drupal Bootstrap sub theme.
  # THEME: "MY_THEME_NAME"
  # Deploy environments configuration, add or remove depending deploy jobs.
  # Testing config, set host or ip
  TESTING_HOST: "localhost"
  # Staging config, set host or ip
  STAGING_HOST: "localhost"
  # Production config, set host or ip
  PRODUCTION_HOST: "localhost"

  ##############################################################################
  # Next part do not need to be edited for a first quick run.
  ##############################################################################
  # Drupal custom code / theme / all code. We are checking our code quality,
  # and optionally Drupal core.
  #
  # Comma separated for phpqa and phpmetrics.
  PHP_CODE: "${WEB_ROOT}/modules/custom,${WEB_ROOT}/themes/custom"
  # Space separated for eslint and sass lint.
  JS_CODE: "${WEB_ROOT}/modules/custom/**/*.js ${WEB_ROOT}/themes/custom/**/*.js"
  CSS_FILES: "${WEB_ROOT}/themes/custom/**/css/"
  SASS_CONFIG: "./.sass-lint.yml"
  # Ignore files and dir for all Phpqa tools.
  PHPQA_IGNORE_DIRS: "--ignoredDirs vendor,bootstrap"
  PHPQA_IGNORE_FILES: "--ignoredFiles Readme.md,style.css,print.css"

  ##############################################################################
  # All reports will be available in artifacts from this folder.
  REPORT_DIR: "reports"

  # Options for Phpqa to build a report to download, need artifacts set on the
  # job, see '.report' below.
  PHPQA_REPORT: "--report --buildDir ${REPORT_DIR}"
  PHPQA_PHP_CODE: "--analyzedDirs ${PHP_CODE} ${PHPQA_IGNORE_DIRS} ${PHPQA_IGNORE_FILES}"
  PHPQA_ALL_CODE: "--analyzedDirs ${WEB_ROOT} ${PHPQA_IGNORE_DIRS} ${PHPQA_IGNORE_FILES}"

################################################################################
# Define your stages, this will be "pipelines" in gitlab.
#   https://docs.gitlab.com/ee/ci/pipelines.html
################################################################################

stages:
  - build
  # Only on branch testing.
  - tests
  # On each push.
  - code quality
  - code lint
  # Only on tag, when released.
  - php code metrics
  # Branch testing or master.
  - deploy to testing
  # Manual if branch staging or master.
  - deploy to staging
  # Manual if branch production or master.
  - deploy to production

################################################################################
# Gitlab ci templates for common jobs to avoid repeat, see
#   https://docs.gitlab.com/ee/ci/yaml/#anchors
################################################################################

# Small repetitive tasks.
.ensure_report:
  - &ensure_report
    mkdir -p ${REPORT_DIR} && chmod -R 777 ${REPORT_DIR}

.default_artifacts: &default_artifacts
  paths:
    - ${REPORT_DIR}/*.html
    - ${REPORT_DIR}/*.svg
  # Name will be based on job and branch.
  name: "${CI_JOB_NAME}-${CI_COMMIT_REF_NAME}"
  # How long do we keep reports files?
  expire_in: 1 day
  # Force artifacts even if the job fail.
  when: always

# Common for all jobs.
before_script:
  - *ensure_report

# Testing template for phpunit, this provide Apache / Php.
.test_template: &test_template
  # Build is not for master but only branch testing or tags for release.
  # Limit to branch push, for more options see
  # https://docs.gitlab.com/ee/ci/yaml/#only-and-except-simplified
  except:
    - master
  only:
    - testing
    - tags
  tags:
    - docker
  services:
    - mariadb:latest
  before_script:
    - *ensure_report
    # Copy CI settings.
    - cp .gitlab-ci/phpunit.xml ${WEB_ROOT}/core/ || true
    # Prepare Drupal installation.
    - cp .gitlab-ci/settings-ci.php ${WEB_ROOT}/sites/default/settings.php || true
    - mkdir -p /var/www/html
    - ln -s "${CI_PROJECT_DIR}/${WEB_ROOT}" /var/www/html/web
    # Prepare tests folder and ensure permissions.
    - mkdir -p "${BROWSERTEST_OUTPUT_DIRECTORY}"
    - chmod -R g+s "${BROWSERTEST_OUTPUT_DIRECTORY}"
    - chown -R www-data:www-data ${WEB_ROOT}
    - apache2-foreground&
    # Import dump from cache if available, or Setup Drupal.
    - mkdir -p dump && chmod -R 777 dump
    - if [ -f "${DB_DUMP}" ];
      then
        mysql -hmariadb -uroot drupal < ${CI_PROJECT_DIR}/${DB_DUMP};
      else
        robo setup:drupal;
      fi
    - robo check:drupal
  variables:
    MYSQL_ALLOW_EMPTY_PASSWORD: "1"
    MYSQL_DATABASE: "drupal"
    DB_DUMP: "dump/dump.sql"
    SIMPLETEST_BASE_URL: "http://localhost"
    SIMPLETEST_DB: "mysql://root@mariadb/drupal"
    BROWSERTEST_OUTPUT_DIRECTORY: "/var/www/html/web/sites/simpletest"
    # Install Drupal from config in ./config/sync with config_installer.
    #SETUP_FROM_CONFIG: "1"
  artifacts:
    <<: *default_artifacts
    paths:
      - ${REPORT_DIR}/*
  cache:
    key: dump-$CI_BUILD_REF_NAME
    paths:
      - dump

################################################################################
# Jobs definition.
#   https://docs.gitlab.com/ee/ci/pipelines.html#jobs
#
# All jobs are set in a stage.
################################################################################

################################################################################
# Build and tests jobs, not for master.
################################################################################

# Sample of a build, considering a Drupal 8 with composer.
# For more samples see
#   https://docs.gitlab.com/ee/ci/examples/deployment/composer-npm-deploy.html
# Two scenarios here:
# - Your repo include only custom code for a composer based project
# - Your repo include at least a composer.json file to install Drupal
Build:
  stage: build
  tags:
    - docker
  except:
    - master
  only:
    - testing
    - tags
  script:
    # Download Drupal project, change with install if providing composer.json file.
    - robo download:drupal-project
    # Because create-project need an empty folder we copy the downloaded drupal.
    - cp -rf drupal/* ${CI_PROJECT_DIR} && cp -f drupal/.e* ${CI_PROJECT_DIR} && cp -f drupal/.g* ${CI_PROJECT_DIR} || true
    #
    # Check composer first, then install.
    # - composer --verbose --no-check-all --no-check-publish validate
    # - robo install:drupal
  # On build we cache the composer, web folders.
  cache:
    key: drupal-$CI_BUILD_REF_NAME
    paths:
      # Remove drupal if install and not create on previous script.
      - drupal
      - vendor
      - web
  artifacts:
    paths:
      # Build files for deploy/release.
      - vendor
      - web
      - drush
      - scripts
      - composer.json
      - composer.lock
      # Specific to drupal-composer/drupal-project.
      - .env.example
      - load.environment.php
    expire_in: 1 day

Security report:
  stage: tests
  tags:
    - docker
  except:
    - master
  only:
    - testing
    - tags
  script:
    - if [ -f "composer.lock" ];
      then
        security-checker security:check;
      fi
  dependencies:
    - Build

# Job to run Unit and Kernel tests.
Unit and kernel tests:
  stage: tests
  <<: *test_template
  script:
    - robo test:suite "${TESTS}unit,${TESTS}kernel" "${REPORT_DIR}"
  dependencies:
    - Build

# Job to check test coverage.
Code coverage:
  stage: tests
  <<: *test_template
  script:
    # Create the needed folders.
    - mkdir -p ${REPORT_DIR}/coverage-xml
    - mkdir -p ${REPORT_DIR}/coverage-html
    - chmod -R 777 ${REPORT_DIR}
    - robo test:coverage "${TESTS}unit,${TESTS}kernel" "${REPORT_DIR}"
  dependencies:
    - Build
  # https://docs.gitlab.com/ee/ci/yaml/#coverage
  coverage: '/^\s*Lines:\s*\d+.\d+\%/'

# Job to run Functional tests, require an install.
Functional:
  stage: tests
  <<: *test_template
  script:
    - robo test:suite "${TESTS}functional" "${REPORT_DIR}"
  dependencies:
    - Build

# https://cgit.drupalcode.org/drupal/tree/core/tests/README.md
Functional Js:
  stage: tests
  <<: *test_template
  services:
    - mariadb:latest
    - name: selenium/standalone-chrome:latest
      alias: chromedriver
  script:
    - test=$(curl -s http://chromedriver:4444/wd/hub/status | jq '.status'); if ! [ $__test=0 ]; then exit 1; fi
    - curl -s http://chromedriver:4444/wd/hub/status | jq '.'
    - robo test:suite "${TESTS}functional-javascript" "${REPORT_DIR}"
  dependencies:
    - Build
  variables:
    MYSQL_ALLOW_EMPTY_PASSWORD: "1"
    MYSQL_DATABASE: "drupal"
    DB_DUMP: "dump/dump.sql"
    SIMPLETEST_BASE_URL: "http://localhost"
    SIMPLETEST_DB: "mysql://root@mariadb/drupal"
    BROWSERTEST_OUTPUT_DIRECTORY: "/var/www/html/web/sites/simpletest"
    MINK_DRIVER_ARGS_WEBDRIVER: '["chrome", {"browserName":"chrome","chromeOptions":{"args":["--disable-gpu","--headless","--no-sandbox"]}}, "http://chromedriver:4444/wd/hub"]'
    APACHE_RUN_USER: "www-data"
    APACHE_RUN_GROUP: "www-data"
  # Because current core tests failed.
  allow_failure: true

# Job to run Functional Javascript tests with Nightwatch.
# https://www.drupal.org/docs/8/testing/javascript-testing-using-nightwatch
# https://cgit.drupalcode.org/drupal/tree/core/tests/README.md
Nightwatch Js:
  stage: tests
  <<: *test_template
  dependencies:
    - Build
  services:
    - mariadb:latest
    - name: selenium/standalone-chrome:latest
      alias: chromedriver
  script:
    - test=$(curl -s http://chromedriver:4444/wd/hub/status | jq '.status'); if ! [ $__test=0 ]; then exit 1; fi
    - curl -s http://chromedriver:4444/wd/hub/status | jq '.'
    - cp .gitlab-ci/.env.chrome ${WEB_ROOT}/core/.env || true
    - cd ${WEB_ROOT}/core
    - yarn install
    - yarn test:nightwatch --tag custom
  variables:
    MYSQL_ALLOW_EMPTY_PASSWORD: "1"
    MYSQL_DATABASE: "drupal"
    BROWSERTEST_OUTPUT_DIRECTORY: "/var/www/html/web/sites/simpletest"
    DOCKER_HOST: tcp://docker:2375
    DOCKER_DRIVER: overlay2
  # Because current core tests failed.
  allow_failure: true

################################################################################
# Code quality jobs for Drupal 8+
################################################################################

# Automated quality check job when something is pushed/merged on master.
# We have a limit on errors we accept on the tools, if failed we run a
# report and stop.
Code quality:
  stage: code quality
  tags:
    - docker
  only:
    - branches
  artifacts:
    <<: *default_artifacts
  script:
    - phpqa ${PHPQA_REPORT} ${TOOLS} ${PHPQA_PHP_CODE}

# Drupal coding standard best practices report.
Best practices:
  stage: code quality
  tags:
    - docker
  only:
    - branches
  artifacts:
    <<: *default_artifacts
  script:
    - sed -i 's/Drupal/DrupalPractice/g' ${CI_PROJECT_DIR}/.phpqa.yml
    # Coding best practices limit, ~20 could be reasonable, to adapt for your project.
    - phpqa ${PHPQA_REPORT}
      --tools phpcs:0
      ${PHPQA_PHP_CODE}
  # Allow failure to produce report and warning.
  allow_failure: true

################################################################################
# Code  lint jobs for Drupal 8+
################################################################################

# Common definition for all lint jobs.
.lint_template: &lint_template
  stage: code lint
  tags:
    - docker
  only:
    - branches
  # Allow failure to produce report and warning, not a critical job.
  allow_failure: true

# This is a eslint report based on Drupal 8.6+ standards.
Js lint:
  <<: *lint_template
  artifacts:
    <<: *default_artifacts
    paths:
      - ${REPORT_DIR}/js-lint-report.html
  before_script:
    # We grab the latest eslint rules for Drupal 8.
    - mkdir -p ${WEB_ROOT}/core
    - curl -fsSL https://cgit.drupalcode.org/drupal/plain/core/.eslintrc.json
      -o ${WEB_ROOT}/core/.eslintrc.json
    # Drupal 8.6.x specfic rules override for passing.
    - curl -fsSL https://cgit.drupalcode.org/drupal/plain/core/.eslintrc.passing.json
      -o ${WEB_ROOT}/core/.eslintrc.passing.json
  script:
    # Run the eslint command to generate a report.
    # Terminal result.
    - eslint
        --config ./${WEB_ROOT}/core/.eslintrc.passing.json
        --debug
        ${JS_CODE}
    # Html report.
    - eslint
        --config ./${WEB_ROOT}/core/.eslintrc.passing.json
        --format html
        --output-file ${REPORT_DIR}/js-lint-report.html
        ${JS_CODE}

# Drupal 8.6+ rules used here for css stylelint.
Css lint:
  <<: *lint_template
  artifacts:
    <<: *default_artifacts
    paths:
      - ${REPORT_DIR}/css-lint-report.txt
  before_script:
    # We grab the Drupal standards, not needed if after a build.
    - curl -fsSL https://cgit.drupalcode.org/drupal/plain/core/.stylelintrc.json
      -o ${WEB_ROOT}/.stylelintrc.json
  script:
    # Terminal result.
    - stylelint "./${CSS_FILES}"
    # Txt report.
    - stylelint "./${CSS_FILES}" > ${REPORT_DIR}/css-lint-report.txt

# This is a sass lint report, default rules used here.
Sass lint:
  <<: *lint_template
  artifacts:
    <<: *default_artifacts
    paths:
      - ${REPORT_DIR}/sass-lint-report.html
  script:
    # Terminal result.
    - sass-lint --config ${SASS_CONFIG} --verbose --no-exit
    # Html report.
    - sass-lint --config ${SASS_CONFIG}
        --verbose
        --no-exit
        --format html
        --output ${REPORT_DIR}/sass-lint-report.html

################################################################################
# Code metrics, would probably make sense only for a tag release.
################################################################################

# Common definition for all metrics jobs.
.metrics_template: &metrics_template
  stage: php code metrics
  # only:
  #   - tags
  tags:
    - docker
  artifacts:
    <<: *default_artifacts

# Phpmetrics report, no pass or failure as it's just informative.
Php metrics:
  <<: *metrics_template
  artifacts:
    <<: *default_artifacts
    paths:
      - ${REPORT_DIR}/
  script:
    - phpqa ${PHPQA_REPORT}
        --tools phpmetrics
        ${PHPQA_PHP_CODE}

# Phploc, Pdepend report, no pass or failure as it's just informative.
Php stats:
  <<: *metrics_template
  script:
    - phpqa ${PHPQA_REPORT}
        --tools phploc,pdepend
        ${PHPQA_PHP_CODE}

# Same reports for all Drupal code including our custom.
# Those commands require enough resources from the runner.
Php metrics All:
  # Need same constaint as Build job.
  except:
    - master
  only:
    - testing
    - tags
  <<: *metrics_template
  script:
    - phpqa ${PHPQA_REPORT}
        --tools phpmetrics
        ${PHPQA_ALL_CODE}
  artifacts:
    <<: *default_artifacts
    paths:
      - ${REPORT_DIR}/
  cache:
    key: drupal-$CI_BUILD_REF_NAME
  dependencies:
    - Build
  when: manual

Php stats All:
  # Need same constaint as Build job.
  except:
    - master
  only:
    - testing
    - tags
  <<: *metrics_template
  script:
    - phpqa ${PHPQA_REPORT}
        --tools phploc,pdepend
        ${PHPQA_ALL_CODE}
  cache:
    key: drupal-$CI_BUILD_REF_NAME
  dependencies:
    - Build
  when: manual
  
################################################################################
# Deploy jobs definition.
#
# This is a sample workflow, testing is run on master and testing branches
# pushes or merge, other deploy are manual. Using a basic bash deploy, you must
# adapt if you are using a specific deploy process.
#
# You need to be sure we can ssh to the machine, a deploy key must be manually
# added on the target in  ~/.ssh/authorized_keys
# Private key name and values must be set on Gitlab:
#   Settings > CI / CD > Variables
################################################################################

# Basic docker image with ssh to be able to access a remote.
# Each access must add a ssh key, see samples below.
.deploy_template: &deploy_template
  image: alpine:latest
  tags:
    - docker
  before_script:
    - apk --no-cache add openssh-client
    - mkdir -p ~/.ssh
    - chmod 700 ~/.ssh
    # Avoid warning on connection.
    - echo -e "Host *\n\tStrictHostKeyChecking no\n\n" > ~/.ssh/config
  when: manual

Deploy to testing:
  stage: deploy to testing
  <<: *deploy_template
  only:
    - testing
    - master
    - tags
  environment:
    name: testing
    url: http://${TESTING_HOST}
  script:
    - echo -e "${TESTING_PRIVATE_KEY}" > ~/.ssh/id_rsa
    - chmod 400 ~/.ssh/id_rsa
    # We can now ssh and run any deploy script.
    # - ssh -T $USER_NAME@$TESTING_HOST
    #     "${DRUPAL_FOLDER}/scripts/my_deploy_script.sh;"

Deploy to staging:
  stage: deploy to staging
  <<: *deploy_template
  only:
    - staging
    - master
    - tags
  environment:
    name: staging
    url: http://${STAGING_HOST}
  script:
    - echo -e "${STAGING_PRIVATE_KEY}" > ~/.ssh/id_rsa
    - chmod 400 ~/.ssh/id_rsa
    # We can now ssh and run any deploy script.
    # - ssh -T $USER_NAME@$TESTING_HOST
    #     "${DRUPAL_FOLDER}/scripts/my_deploy_script.sh;"

Deploy to production:
  stage: deploy to production
  <<: *deploy_template
  only:
    - production
    - master
    - tags
  environment:
    name: production
    url: http://${PRODUCTION_HOST}
  script:
    - echo -e "${PRODUCTION_PRIVATE_KEY}" > ~/.ssh/id_rsa
    - chmod 400 ~/.ssh/id_rsa
    # We can now ssh and run any deploy script.
    # - ssh -T $USER_NAME@$TESTING_HOST
    #     "${DRUPAL_FOLDER}/scripts/my_deploy_script.sh;"

# Base image for all ci, see https://gitlab.com/mog33/drupal8ci
image: mogtofu33/drupal8ci:8.6

Aidez moi à mettre ça en place sur mon projet !

Si vous n'avez pas le temps ou les compétences, je peux vous aider par une prestation à mettre en place et personnaliser cette gestion sur votre projet, vous pouvez simplement me contacter.

Commentaires