Вступление
Привет, Хабр! Это мой первый пост на данной площадке, давно читаю, но писать все не решался, но, как говорится, когда-то все в жизни бывает в первый раз.
Коротко о том, что будет в статье
быстро настроим 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 | gitlab-ce server 16.1.0-ce.0 | |
Gitlab Runner | gitlab build runner |
Для того чтобы иметь возможность тестировать код и собирать 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-проектов для того, чтобы показать, как организована структура файлов у меня.


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

Глава 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 (заранее прошу прощения за свой английский, я только учусь).
