Android-разработка для самых маленьких

от автора

Вступление

Привет, Хабр! Это мой первый пост на данной площадке, давно читаю, но писать все не решался, но, как говорится, когда-то все в жизни бывает в первый раз.

Коротко о том, что будет в статье

  • быстро настроим Gitlab для имитации CI-CD как у взрослых дядь (образно выражаясь)

  • напишем простое Hello world web-приложение (без излишеств, максимально быстро и просто)

  • превратим наше web-приложение в Android-приложение при помощи Cordova (как мне кажется, самый простой метод, для не вовлеченных в индустрию людей)

  • соберем тестовую версию .apk приложения и запустим на Android-телефоне

Дисклеймер

Web- и Android-разработка не являются моими прямыми должностными обязанностями, это мое хобби, на которое я предпочитаю тратить свободное от работы и прочих дел время, я понимаю, что в некоторых случаях могу показать антипаттерн в методах реализации демонстрационного проекта. Надеюсь, вы подсветите мне все нюансы в комментариях! Цель статьи — показать общую концепцию одного из вариантов реализации конвейера для быстрого выпуска простых Android-приложений

Глава 1. Gitlab CI-CD

Основная задача на данном этапе — получить локальный инстанс Gitlab-CE и раннеры для того, чтобы можно было хранить наш код, иметь систему контроля версий и быстро собирать продукт из исходников.

Воспользуемся контейнеризацией (Docker + Docker Compose) для того, чтобы упростить процесс деплоя Gitlab на локальную машину, нам понадобится собственно Gitlab-CE и несколько раннеров (в моем примере — 3), для того чтобы не только хранить код, но и собирать проект (таблица 1).

таблица 1 — Используемые образы

Наименование

Ссылка на Docker hub

Описание

Gitlab-CE

https://hub.docker.com/r/gitlab/gitlab-ce

gitlab-ce server 16.1.0-ce.0

Gitlab Runner

https://hub.docker.com/r/gitlab/gitlab-runner

gitlab build runner alpine3.18-v16.1.0 shell

Для того чтобы иметь возможность тестировать код и собирать web- и android-приложения, нам понадобится модифицировать образ Gitlab Runner под свои нужды; так как мы взяли за основу версию Alpine, сделать это будет совсем просто, напишем простой Dockerfile, для того чтобы добавить все необходимое:

FROM gitlab/gitlab-runner:alpine3.18-v16.1.0  # set LANG env var ENV LANG=C.UTF-8  #add Glibc 2.34 for Alpine linux RUN ALPINE_GLIBC_BASE_URL="https://github.com/sgerrand/alpine-pkg-glibc/releases/download" && \     ALPINE_GLIBC_PACKAGE_VERSION="2.34-r0" && \     ALPINE_GLIBC_BASE_PACKAGE_FILENAME="glibc-$ALPINE_GLIBC_PACKAGE_VERSION.apk" && \     ALPINE_GLIBC_BIN_PACKAGE_FILENAME="glibc-bin-$ALPINE_GLIBC_PACKAGE_VERSION.apk" && \     ALPINE_GLIBC_I18N_PACKAGE_FILENAME="glibc-i18n-$ALPINE_GLIBC_PACKAGE_VERSION.apk" && \     apk add --no-cache --virtual=.build-dependencies wget ca-certificates && \     echo \         "-----BEGIN PUBLIC KEY-----\         MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApZ2u1KJKUu/fW4A25y9m\         y70AGEa/J3Wi5ibNVGNn1gT1r0VfgeWd0pUybS4UmcHdiNzxJPgoWQhV2SSW1JYu\         tOqKZF5QSN6X937PTUpNBjUvLtTQ1ve1fp39uf/lEXPpFpOPL88LKnDBgbh7wkCp\         m2KzLVGChf83MS0ShL6G9EQIAUxLm99VpgRjwqTQ/KfzGtpke1wqws4au0Ab4qPY\         KXvMLSPLUp7cfulWvhmZSegr5AdhNw5KNizPqCJT8ZrGvgHypXyiFvvAH5YRtSsc\         Zvo9GI2e2MaZyo9/lvb+LbLEJZKEQckqRj4P26gmASrZEPStwc+yqy1ShHLA0j6m\         1QIDAQAB\         -----END PUBLIC KEY-----" | sed 's/   */\n/g' > "/etc/apk/keys/sgerrand.rsa.pub" && \     wget \         "$ALPINE_GLIBC_BASE_URL/$ALPINE_GLIBC_PACKAGE_VERSION/$ALPINE_GLIBC_BASE_PACKAGE_FILENAME" \         "$ALPINE_GLIBC_BASE_URL/$ALPINE_GLIBC_PACKAGE_VERSION/$ALPINE_GLIBC_BIN_PACKAGE_FILENAME" \         "$ALPINE_GLIBC_BASE_URL/$ALPINE_GLIBC_PACKAGE_VERSION/$ALPINE_GLIBC_I18N_PACKAGE_FILENAME" && \     mv /etc/nsswitch.conf /etc/nsswitch.conf.bak && \     apk add --no-cache --force-overwrite \         "$ALPINE_GLIBC_BASE_PACKAGE_FILENAME" \         "$ALPINE_GLIBC_BIN_PACKAGE_FILENAME" \         "$ALPINE_GLIBC_I18N_PACKAGE_FILENAME" && \     \     mv /etc/nsswitch.conf.bak /etc/nsswitch.conf && \     rm "/etc/apk/keys/sgerrand.rsa.pub" && \     (/usr/glibc-compat/bin/localedef --force --inputfile POSIX --charmap UTF-8 "$LANG" || true) && \     echo "export LANG=$LANG" > /etc/profile.d/locale.sh && \     \     apk del glibc-i18n && \     \     rm "/root/.wget-hsts" && \     apk del .build-dependencies && \     rm \         "$ALPINE_GLIBC_BASE_PACKAGE_FILENAME" \         "$ALPINE_GLIBC_BIN_PACKAGE_FILENAME" \         "$ALPINE_GLIBC_I18N_PACKAGE_FILENAME"  # set env vars ENV ANDROID_SDK_ROOT "/opt/sdk" ENV ANDROID_HOME ${ANDROID_SDK_ROOT} ENV PATH $PATH:${ANDROID_SDK_ROOT}/gradle/6.5/bin:${ANDROID_SDK_ROOT}/cmdline-tools/latest_supported/bin:${ANDROID_SDK_ROOT}/platform-tools:${ANDROID_SDK_ROOT}/extras/google/instantapps:${ANDROID_SDK_ROOT}/build-tools/34.0.0  # add core apps and libs (jdk8, jdk11, cordova10) RUN apk add --no-cache --update \     docker docker-compose openrc doas nodejs-current npm curl python3 python3-dev py3-pip gcc musl-dev openjdk8 openjdk11 curl unzip wget RUN python3 -m pip install --upgrade --no-cache semgrep && \     npm install -g cordova@10 RUN rc-update add docker boot RUN echo 'permit nopass gitlab-runner as root' > /etc/doas.d/doas.conf  # add Gradle 6.5 RUN wget -q https://services.gradle.org/distributions/gradle-6.5-all.zip -O /tmp/gradle.zip && \     mkdir -p ${ANDROID_SDK_ROOT}/gradle && \     unzip -qq /tmp/gradle.zip -d ${ANDROID_SDK_ROOT}/gradle && \     mv ${ANDROID_SDK_ROOT}/gradle/* ${ANDROID_SDK_ROOT}/gradle/6.5 && \     rm -v /tmp/gradle.zip  # add latest android command line tools RUN wget -q https://dl.google.com/android/repository/commandlinetools-linux-9477386_latest.zip -O /tmp/tools.zip && \     mkdir -p ${ANDROID_SDK_ROOT}/cmdline-tools && \     unzip -qq /tmp/tools.zip -d ${ANDROID_SDK_ROOT}/cmdline-tools && \     mv ${ANDROID_SDK_ROOT}/cmdline-tools/* ${ANDROID_SDK_ROOT}/cmdline-tools/latest_supported && \     rm -v /tmp/tools.zip && \     mkdir -p ~/.android/ && touch ~/.android/repositories.cfg  # add android platform tools, build tools 34.0.0 RUN yes | sdkmanager --sdk_root=${ANDROID_SDK_ROOT} --licenses && \     sdkmanager --sdk_root=${ANDROID_SDK_ROOT} --install "platform-tools" "extras;google;instantapps" "build-tools;34.0.0"  # remove jdk11 RUN apk del openjdk11  # fix build tools "dx" bug RUN mv /opt/sdk/build-tools/34.0.0/d8 /opt/sdk/build-tools/34.0.0/dx &&\     mv /opt/sdk/build-tools/34.0.0/lib/d8.jar /opt/sdk/build-tools/34.0.0/lib/dx.jar  # set ownership of android sdk to gitlab-runner user RUN chown gitlab-runner -R /opt/sdk  # set default jdk to jdk8 ENV JAVA_HOME "/usr/lib/jvm/java-1.8-openjdk" ENV PATH $PATH:${JAVA_HOME}/bin 

Из необычного хотелось бы отметить несколько моментов:

  • добавление библиотеки Glibc (строка 6)

  • добавление jdk8 и jdk11 (строка 52)

  • удаление jdk11 (строка 79)

  • фикс нэйминга файлов Android SDK (строка 82)

Теперь по порядку. Glibc нам понадобится, чтобы поставить все компоненты, необходимые для сборки Android-приложения, при помощи Cordova (я не стал искать сложных путей и воспользовался уже готовым решением из Github). Jdk11 нам понадобится для того, чтобы иметь возможность использовать Android command line tools, впоследствии мы его удалим, так как использовать будем Cordova 10, которая дружит с jdk8 (пожалуйста, не ругайте за легаси, да, я тоже слышал шутку Влада Тэна в подкасте, где он прерывает влажные фантазии о AI фразой в духе «Какой к черту AI, люди до сих пор 8 джавой пользуются!»). Фикс нэйминга файлов Android SDK нам понадобится для того, чтобы Gradle не падал с ошибкой на этапе сборки андроид-приложения.

Помимо вышеописанных действий мы также добавим в контейнер Nodejs, Python, Semgrep и Gradle и выполним ряд дополнительных манипуляций (в Dockerfile есть комментарии, не буду их дублировать)

Теперь у нас есть все для того, чтобы создать импровизированную версию конвейера в домашних условиях, давайте напишем docker-compose.yml

version: "3.9"  networks:   gitlab_ce:     driver: bridge  volumes:   gitlab_conf:   gitlab_data:   gitlab_logs:   build_runner_home:   build_runner_conf:   test_runner_home:   test_runner_conf:   deploy_runner_home:   deploy_runner_conf:  services:   # gitlab-ce   gitlab-ce:     image: gitlab/gitlab-ce:16.1.0-ce.0     shm_size: '2gb'     container_name: gitlab-ce     hostname: gitlab-ce.local     restart: always     depends_on:       - build-runner       - test-runner       - deploy-runner     volumes:       - gitlab_conf:/etc/gitlab       - gitlab_data:/var/opt/gitlab       - gitlab_logs:/var/log/gitlab     ports:       - "127.0.0.1:22:22"       - "127.0.0.1:80:80"     networks:       - gitlab_ce     deploy:       resources:         limits:           cpus: "4"           memory: 8G    # gitlab-build-runner   build-runner:     build: ./     container_name: gitlab-build-runner     hostname: gitlab-build-runner.local     restart: always     volumes:       - build_runner_conf:/etc/gitlab-runner       - build_runner_home:/home/gitlab-runner       - /var/run/docker.sock:/var/run/docker.sock:rw     networks:       - gitlab_ce     deploy:       resources:         limits:           cpus: "4"           memory: 4G    # gitlab-test-runner   test-runner:     build: ./     container_name: gitlab-test-runner     hostname: gitlab-test-runner.local     restart: always     volumes:       - test_runner_conf:/etc/gitlab-runner       - test_runner_home:/home/gitlab-runner       - /var/run/docker.sock:/var/run/docker.sock:rw     networks:       - gitlab_ce     deploy:       resources:         limits:           cpus: "1"           memory: 1G    # gitlab-deploy-runner   deploy-runner:     build: ./     container_name: gitlab-deploy-runner     hostname: gitlab-deploy-runner.local     restart: always     volumes:       - deploy_runner_conf:/etc/gitlab-runner       - deploy_runner_home:/home/gitlab-runner       - /var/run/docker.sock:/var/run/docker.sock:rw     networks:       - gitlab_ce     deploy:       resources:         limits:           cpus: "1"           memory: 1G 

Стоит обратить внимание на секцию deploy в каждом сервисе, в ней можно указать, сколько системных ресурсов Вы хотите утилизировать под каждый сервис, также внимание стоит обратить на build: здесь указывается, из какой директории собирать образ для сервиса (где находится Dockerfile), не пропустите shm_size — это поможет заметно повысить производительность сервера Gitlab.

В данной конфигурации заложена избыточность по количеству раннеров (можно обойтись и одним, или же для каждой стадии (build, test, deploy) использовать отдельный образ, оптимизированный под определенную задачу) альтернативным методом может быть использование раннера c типом docker, однако в данном примере, несмотря на то что все раннеры являются Docker-контейнерами, я буду использовать их как shell-раннеры. Да, здесь используется импровизированный docker in docker (у сообщества есть вопросы к целесообразности использования такого метода не в последнюю очередь из-за проблем с безопасностью, но в целях эксперимента, думаю, можно попробовать и такую конфигурацию).

Теперь давайте автоматизируем процесс регистрации раннера (как вы знаете, недостаточно просто запустить раннер, его еще нужно зарегистрировать на сервере Gitlab)

#!/bin/sh  RUNNER_CONTAINER_ID=$1 RUNNER_TAG=$2 GITLAB_RUNNER_REG_TOKEN=$3  GITLAB_SUBNET=$(docker network ls | grep gitlab_ce | cut -d " " -f 4)  echo "Registering ...";  docker exec -it $RUNNER_CONTAINER_ID bash -c 'gitlab-runner register \   --non-interactive \   --executor "shell" \   --docker-image alpine:latest \   --url "http://gitlab-ce.local/" \   --registration-token "'$GITLAB_RUNNER_REG_TOKEN'" \   --description "docker-'$RUNNER_TAG'-runner" \   --maintenance-note "Docker runner for gitlab-ce" \   --tag-list "docker,'$RUNNER_TAG'" \   --run-untagged="true" \   --locked="false" \   --access-level="not_protected" > /dev/null 2>&1; \   echo "    network_mode = \"'$GITLAB_SUBNET'\"" >> /etc/gitlab-runner/config.toml';   echo "Done" 

Данный скрипт принимает id контейнера с раннером, тэг для него и регистрационный токен. В ходе выполнения скрипта в контейнер передается команда регистрации раннера, также вносится правка в его конфигурацию, для того чтобы указать сеть, из которой ему будет доступен сервер Gitlab.

Почти все готово для запуска своего инстанса Gitlab, финальный скрипт, связывающий все в единую картину и автоматизирующий рутину.

#!/bin/sh  echo "################### Build & deploy Gitlab-CE ###################" docker compose -p gitlab-ce up -d --build --wait gitlab-ce echo "###############################################################"  echo "..."  echo "#################### Check Gitlab-CE state ####################" docker compose -p gitlab-ce ps echo "###############################################################"  echo "..."  echo "############## Gitlab runner registration token ###############" RUNNER_REG_TOKEN=$(docker exec -it gitlab-ce gitlab-rails runner -e production 'puts Gitlab::CurrentSettings.current_application_settings.runners_registration_token') echo "Token: $RUNNER_REG_TOKEN" echo "###############################################################"  echo "..."  echo "################ Gitlab initial root password #################" docker exec -it gitlab-ce bash -c 'grep 'Password:' /etc/gitlab/initial_root_password 2>/dev/null || echo "Custom root password already exists."' echo "###############################################################"  echo "..."  echo "################# Base runners registration ###################" echo "Registering  gitlab-build-runner ..." ./reg_new_runner.sh gitlab-build-runner build $RUNNER_REG_TOKEN echo "Registering  gitlab-test-runner ..." ./reg_new_runner.sh gitlab-test-runner test $RUNNER_REG_TOKEN echo "Registering  gitlab-deploy-runner ..." ./reg_new_runner.sh gitlab-deploy-runner deploy $RUNNER_REG_TOKEN echo "###############################################################" 

В итоге получаем готовый к тестам Gitlab-инстанс, который может собирать приложения и тестировать код.

зарегестрированныые раннеры

зарегестрированныые раннеры

Глава 2. Простое web-приложение

Как вы уже, наверное, догадались, здесь мы сделаем очень простое web-приложение и соберем его при помощи Webpack, на эту тему есть очень много хороших материалов, официальная документация тоже на высоте.

От себя лишь добавлю пару скриншотов из своих pet-проектов для того, чтобы показать, как организована структура файлов у меня.

файлы web-приложения выделены красным

файлы web-приложения выделены красным
содержание папки src

содержание папки src

Не буду подробно останавливаться на этом пункте, единственное, на что обращу ваше внимание, задайте в качестве директории, куда будет собираться Ваш фронтенд, папку с названием www, это упростит понимание дальнейших процессов.

конфигурация webpack

конфигурация webpack

Глава 3. Hi Cordova, pls transform my website to android app

Подведем промежуточные итоги: у нас есть простое web-приложение и Gitlab, готовый собирать наш проект и тестировать его, давайте соберем все компоненты нашего пазла воедино.

Для начала добавим конфигурационный файл config.xml для нашего приложения (я положу его в папку Cordova).

<?xml version='1.0' encoding='utf-8'?> <widget id="com.moviefinder.main" version="1.0.0" xmlns="http://www.w3.org/ns/widgets" xmlns:cdv="http://cordova.apache.org/ns/1.0" xmlns:android="http://schemas.android.com/apk/res/android">     <name>Hello-world-app</name>     <description>My first app</description>     <author email="username@domain.com" href="https://link-to-my.app">         Name Surname     </author>          <preference name="android-targetSdkVersion" value="34" />     <preference name="android-minSdkVersion" value="21" />          <preference name="AndroidPersistentFileLocation" value="Compatibility" />          <preference name="DisallowOverscroll" value="true" />          <allow-navigation href="*" />     <allow-intent href="*" />     <access origin="*" />          <edit-config file="app/src/main/AndroidManifest.xml" mode="merge" target="/manifest/application/activity">         <activity android:exported="true"/>     </edit-config>    </widget> 

Обратите внимание на секцию edit-config: в ней задается параметр android:exported=»true» — это необходимо для того, чтобы ваше приложение было доступно на последних версиях Android.

Так как наше андроид-приложение — это по факту еще и обычный веб-сайт, добавим сборку web-приложения в Nginx-контейнере, для того чтобы мы сразу могли видеть результат нашей работы (например, если у нас нет под рукой android-эмулятора или устройства, на которое можно поставить приложение), для этого создадим Dockerfile со следующим содержанием:

FROM nginx COPY ./www /usr/share/nginx/html 

Таким образом мы получим на выходе веб-сервер с нашим web-приложением (ранее мы собирали наш web-проект в папку www)

Почти все готово, осталось только написать .gitlab-ci.yml конфигурацию для нашего пайплайна, давайте сделаем это!

stages:   - build   - test   - deploy  build-code-job:   stage: build   tags:     - build     - docker   before_script:     - curl "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/sendMessage?chat_id=$TELEGRAM_CHAT_ID&text=%20?%20%5B$CI_PROJECT_NAME%5D%20Build%20job%20started"   script:     - PATH=$PATH:/opt/sdk/gradle/6.5/bin     - mkdir -p ~/.android/ && touch ~/.android/repositories.cfg     - npm i     - npm run build     - doas docker build -t $CI_PROJECT_NAME .     - cordova create ${CI_PROJECT_NAME}     - cd ${CI_PROJECT_NAME}     - rm -Rf ./www/*     - cp -r ../www/* ./www/     - cp ../cordova/* ./     - cordova platform add android     - cordova build android   cache:     paths:       - node_modules   artifacts:     paths:       - ./${CI_PROJECT_NAME}/platforms/android     expire_in: 1 week     name: ${CI_PROJECT_NAME}_${CI_JOB_ID}_android   after_script:     - >       if [ $CI_JOB_STATUS == 'success' ]; then         curl "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/sendMessage?chat_id=$TELEGRAM_CHAT_ID&text=%20✅%20%5B$CI_PROJECT_NAME%5D%20Build%20job%20completed"         curl -F document=@"./${CI_PROJECT_NAME}/platforms/android/app/build/outputs/apk/debug/app-debug.apk" https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/sendDocument?chat_id=$TELEGRAM_CHAT_ID       else         curl "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/sendMessage?chat_id=$TELEGRAM_CHAT_ID&text=%20❌%20%5B$CI_PROJECT_NAME%5D%20Build%20job%20failed%20$CI_PIPELINE_URL"       fi  test-code-job:   stage: test   tags:     - test     - docker   script:     - doas docker image ls | grep "$CI_PROJECT_NAME"     - semgrep scan src --config auto --output $CI_PROJECT_NAME-sast-report.json --json   artifacts:     paths:       - $CI_PROJECT_NAME-sast-report.json     expire_in: 4 week     name: ${CI_PROJECT_NAME}_${CI_JOB_ID}_sast_report   after_script:     - >       if [ $CI_JOB_STATUS == 'success' ]; then         curl "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/sendMessage?chat_id=$TELEGRAM_CHAT_ID&text=%20✅%20%5B$CI_PROJECT_NAME%5D%20All%20tests%20passed%20successfuly"         curl -F document=@"$CI_PROJECT_NAME-sast-report.json" https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/sendDocument?chat_id=$TELEGRAM_CHAT_ID       else         curl "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/sendMessage?chat_id=$TELEGRAM_CHAT_ID&text=%20❌%20%5B$CI_PROJECT_NAME%5D%20Tests%20failed%20$CI_PIPELINE_URL"       fi  deploy-code-job:   stage: deploy   tags:     - deploy     - docker   script:     - doas docker stop $CI_PROJECT_NAME || true && doas docker rm $CI_PROJECT_NAME || true     - doas docker run -d --name $CI_PROJECT_NAME -p $HOST_PORT:80 --restart unless-stopped $CI_PROJECT_NAME   after_script:     - >       if [ $CI_JOB_STATUS == 'success' ]; then         curl "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/sendMessage?chat_id=$TELEGRAM_CHAT_ID&text=%20?%20%5B$CI_PROJECT_NAME%5D%20Deploy%20job%20completed%20$PROJECT_URL"       else         curl "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/sendMessage?chat_id=$TELEGRAM_CHAT_ID&text=%20❌%20%5B$CI_PROJECT_NAME%5D%20Deploy%20job%20failed%20$CI_PIPELINE_URL"       fi 

Данная конфигурация включает 3 шага:

  • build

  • test

  • deploy

Вы, конечно же, понимаете, что происходит на каждом этапе, но я поясню нюансы: помимо непосредственно сборки, тестирования и внедрения происходит также процесс информирования в Telegram-канал. Обратите внимание на условие в after_script — [ $CI_JOB_STATUS == ‘success’ ] — таким образом мы понимаем, выполнены ли все шаги успешно или же есть ошибки, и в зависимости от статуса конкретного этапа отправляем соответствующее сообщение.

Также обратите внимание на артефакты; на стадии сборки мы получаем в качестве артефакта папку с андроид-проектом — его потом можно открыть в Android Studio, а на стадии тестирования мы получаем отчет SAST-тестирования (вот, наконец, нам и пригодился Python и Semgrep)

Также в случае успешной сборки в Телеграм отправляется apk-файл с приложением и после прохождения тестирования SAST отчет также дублируется в мессенджер.

пример оповещений в телеграм

пример оповещений в телеграм

Полученный .apk файл можно запустить на телефоне и протестировать, а тот факт, что файл автоматически выгружается в мессенджер, поможет поделиться с вашей группой тестирования (если, конечно, она у вас есть)

И финальный штрих — определим переменные окружения для нашего проекта:

переменные окружения

переменные окружения

Теперь нам достаточно запушить изменения в репозиторий или запустить пайплайн вручную, для того чтобы конвейер заработал:

Спасибо за внимание, буду рад услышать любые советы и рекомендации от настоящих разработчиков, напоминаю: в этой области я не специалист, просто делюсь своим хобби с вами.


Небольшое отступление от темы: я веду любительский канал на youtube, где пытаюсь совершенствовать свой английский, рассказывая о своих любительских проектах, там наглядно показано,н как работает эта конфигурация Gitlab (заранее прошу прощения за свой английский, я только учусь).


ссылка на оригинал статьи https://habr.com/ru/articles/749342/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *