Карта дня, май, перетяжка
Карта дня, май, перетяжка
Карта дня, май, перетяжка

Конвейер DevOps, часть 3: пайплайны и хуки в Git

В этой серии статей Олег Филон, ментор Эйч Навыки, рассказывает, как прийти к крутому CI/CD пайплайну. Сегодня разбираемся, как работать с пайплайнами и хуками в Git.

2К открытий6К показов
Конвейер DevOps, часть 3: пайплайны и хуки в Git

Я — Олег Филон, ментор Эйч Навыки и Senior DevOps Engineer. В этой статье расскажу, как организовать CI/CD пайплайн для контейнеризованного проекта с использованием утилиты make, сравню подходы для Docker и Podman, а также поделюсь хаком с использованием Git bare репозитория для автоматизации деплоя.

Первые две части лежат здесь: рабочее место/облако и Fedora Core/mise.

Начало проекта и утилита make

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

Первый — классический пайплайн — это утилита make. Обычно она используется для сборки программ из исходного кода. На самом деле make хорошо подходит для решения сразу нескольких задач.

  • Первая задача — отслеживание зависимостей одних файлов от других, например, при изменении сервиса пересобрать только соответствующий контейнер. 
  • Вторая задача, легко реализуемая через make — сборка в один файл много команд или скриптов, чтобы удобно их организовать. Как правило, сборка образа, его загрузка в репо, удаление временных файлов и прочее делается несколькими рутинными командами. Точно так же можно поместить в Makefile команды запуска сервисов и тестирование приложения локально. 
  • Если эти этапы прошли успешно, можно выполнить коммит кода в репо проекта, сделать деплой в dev или stage environment. В github actions это называется jobs и steps. В make такая группа команд называется целью, она указывается параметром при вызове. 

Так, несмотря на разную терминологию, по сути можно создать полноценный пайплайн для современного проекта с контейнеризованными сервисами.

Разбираемся на практике

Возьмём для примера проект с прокси сервером traefik и бэкендом на golang из репозитария awesome-pods. Этот репо задуман как форк замечательного проекта awesome-compose, в котором собраны конфиги docker compose для 41 самого популярного сервиса. Я же пытаюсь сделать что-то похожее для манифестов podman. Приглашаю к сотрудничеству начинающих девопс — сможете поучаствовать в открытом проекте, заработать почётные гитхаб-бейджи и улучшить своё резюме. Подробнее — здесь.

Мой проект в интересном положении: сделаны манифесты для нескольких сервисов, опробованы описанные выше подходы для миграции конфигов compose.yaml в манифесты kube.yaml. Но захотелось большего: а почему бы не сделать сразу пайплайны для тестирования, коммита в апстрим, деплоя и прочее. Зайдём в каталог traefik-golang и создадим пару мейк-файлов. Для начала сделаем всё это локально, начнём с make_compose:

			... $ cat make_compose
.PHONY: help build up down stop restart logs test ps stats shell
help:
       @echo "make_compose avalable targets:\n"
       @make -pRrq 2>/dev/null|grep -v ^Makefile|grep -P '^\w+:'|sort
build:
       docker compose build
up:
       docker compose up -d
down:
       docker compose down -v
stop:
       docker compose stop
restart:
       docker compose stop
       docker compose up -d
logs:
       docker compose logs
test:
       curl localhost:8080
ps:
       docker compose ps -a
stats:
       docker compose stats --no-stream
shell:
       docker exec -ti traefik /bin/sh

		

Этот файл уже в истории, равно как и соответствующий README.md, привожу его для примера. Так как я делаю конфиги сразу для двух платформ — docker и podman, для включения соответствующего Makefile’а нужно сделать линк на него: ln -s make_compose Makefile.

Отлично, основную идею обсудили, идём дальше. В docker’е есть замечательная опция context, позволяющая работать с любыми серверами, где настроен доступ. В нашем случае список контекстов выглядит так:

			... $ docker context ls
NAME            DESCRIPTION                               DOCKER ENDPOINT                                  ERROR
deb12fri        vm on https://cloud.ru                    ssh://deb12fri                                   
default         Current DOCKER_HOST based configuration   unix:///var/run/docker.sock                      
desktop-linux   Docker Desktop                            unix:///home/ophil/.docker/desktop/docker.sock   
fc42dev *       libvirt vm fc42dev                        ssh://dev@fc42dev
localhost       localhost for make_compose pipeline       unix:///var/run/docker.sock

		

Здесь я использовал простейший хак — сделал копию дефолтного контекста с именем localhost. Теперь мы можем сделать наш пайплайн способным на удалённый деплой. Достаточно прописать в /etc/hosts имя и адрес нашего dev сервера. Вот новая версия make_compose:

			... $  nl make_compose
     1  .PHONY: help build up down clean commit stop restart logs test ps stats shell
     2  ifndef DKR_CONTEXT
     3    DKR_CONTEXT = localhost
     4    endif
     5  help:
     6          @echo "make sure you in proper docker context: ${DKR_CONTEXT}"
     7          docker context use ${DKR_CONTEXT}
     8          docker context ls 
     9          @echo "make_compose avalable targets:"
    10          @make -pRrq 2>/dev/null|grep -v ^Makefile|grep -P '^\w+:'|sort
    11  build: main.go make_compose
    12          docker compose build
    13  up: build down
    14          docker compose up -d
    15  clean:
    16          docker system prune -f
    17  commit: test
    18          git branch -v
    19          @read -p "Enter commit message:" mesg;\
    20          git commit -am "$$mesg"
    21          git push
    22  down:
    23          docker compose down -v
    24  stop:
    25          docker compose stop
    26  restart:
    27          docker compose stop
    28          docker compose up -d
    29  logs:
    30          docker compose logs
    31  test:
    32          curl ${DKR_CONTEXT}:8080
    33  ps:
    34          docker compose ps -a
    35  stats:
    36          docker compose stats --no-stream
    37  shell:
    38          docker exec -ti traefik /bin/sh

		

Поясню немного подробнее.

  • Самая первая строка — стандартное объявление списка целей. 
  • Строки 2-4 задают дефолтное значение переменной, если оно не задано в текущем env
  • В хелп — строки 5-10 — добавлено предупреждение о текущем контексте, он задаётся в глобальной переменной, например, export DKR_CONTEXT=localhost для локального контекста. 
  • Также добавлена цель commit в репо — строки 17-21 — после выполнения цели test. 
  • Test — строки 31-32 — в свою очередь, выполняется для текущего контекста, см. хак #1. Имя контекста должно совпадать с именем хоста нашего dev-сервера. 
  • Добавлена также цель clean: очистка старых образов с локальном репо,и зависимости в цель up. Здесь убеждаемся, что образ пересобран и старые контейнеры остановлены.

Отлично, пайплайн для докера работает. Пробуем сделать то же самое для подмана. Здесь нас ждёт сюрприз, попробую рассказать в стиле прямого репортажа. Первоначально наш пайплайн для podman выглядел вот так:

			... $ nl make_pods 
     1	.PHONY: help up clean down logs test ps stats shell
     2	help:
     3	        @echo -e "make_pods avalable targets:\n"
     4	        @make -pRrq 2>/dev/null|grep -v ^Makefile|grep -P '^\w+:'|sort
     5	back: main.go make_pods
     6	        -buildah rm backend
     7	        buildah from --name backend scratch
     8	        CGO_ENABLED='0' go build -v -ldflags "-w -s" -o back main.go
     9	        buildah copy backend back /usr/local/bin/backend
    10	        buildah config --entrypoint '["/usr/local/bin/backend"]' backend
    11	        buildah commit backend backend:latest
    12	clean:
    13	        podman system prune -f
    14	down:
    15	        envsubst < kube.yaml | podman kube down -
    16	up: down
    17	        envsubst < kube.yaml | podman kube play -
    18	logs:
    19	        podman pod logs go-app
    20	test:
    21	        curl localhost:8080
    22	ps:
    23	        podman ps -ap
    24	stats:
    25	        podman stats --no-stream
    26	shell:
    27	        podman exec -ti go-app-traefik /bin/sh
		

В строке 5 определяются зависимости: target back соберёт исполняемый файл только в том случае, если код main.go или сам make_pods новее уже собранного бинарника.

Строка 6 удаляет backend контейнер с едва заметным знаком минус -, чтобы игнорировать ошибку, если контейнер с именем backend не существует.

Строки 7–10 создают контейнер с именем backend из пустого (scratch) контейнера — команды buildah следуют обычным командам Dockerfile, но в нижнем регистре: FROM -> from, COPY -> copy, RUN -> run, ENTRYPOINT -> config –entrypoint и т. д. Здесь вы видите основное отличие от традиционного docker buildx подхода — вы работаете в двух контекстах одновременно: в локальном контексте, используя установленный компилятор go, и в контексте контейнера, копируя файлы в/из контейнера, запуская команды внутри контейнера и т. д. Другая новая возможность buildah — вы можете собирать образ шаг за шагом, то есть отлаживать процесс сборки.

Строка 8 компилирует main.go в исполняемый файл back с соответствующими флагами.

Строка 11 создаёт из контейнера новый образ (image) с тегом backend:latest.

Цель up — строка 16 — зависит от цели down — строка 14, — то есть она сначала останавливает pod и удаляет контейнеры, если они всё ещё запущены, затем запускает новый под.

Цель down в строке 15 подставляет глобальную переменную $XDG_RUNTIME_DIR из env пользователя в kube.yaml, используемый далее в podman kube командах, принимая новый манифест со стандартного ввода. Это также специфика podman — он работает полностью в пространстве пользователя, контейнеры взаимодействуют через собственный podman.sock. Таким образом, делаем пайплайн независимым от UID.

В подмане есть фунциональность наподобие docker context, под другим именем, в подкоманде system connection:

			$ podman system connection add --identity ~/.ssh/id_ed25519 --port 22 fc41dev dev@fc41dev
$ podman system connection list
Name        URI                                                          Identity                     Default
fc42dev     ssh://dev@fc42dev:22/run/user/1001/podman/podman.sock        /home/ophil/.ssh/id_ed25519  true
proba       ssh://core@127.0.0.1:37973/run/user/1000/podman/podman.sock  /home/ophil/.ssh/proba       false
proba-root  ssh://root@127.0.0.1:37973/run/podman/podman.sock            /home/ophil/.ssh/proba       false

		

Первым в списке стоит настроенная в прошлой статье ВМ. Пока искал правильные опции для создания коннекшена (aka контекст в докере), столкнулся с подсказкой от подмана — «создайте сначала машину», а именно:

			$ podman system df
Cannot connect to Podman. Please verify your connection to the Linux system using
"podman system connection list", or try "podman machine init" and "podman machine start"
to manage a new Linux VM

		

Выполнил эти рекомендации, подман выкачал, настроил и добавил два новых коннекшена для новой ВМ. Какой же меня ждал сюрприз, когда я стал смотреть, что же это за machine. Во-первых, в моём HOME появились новые файлы и каталоги:

			$ ll ~/.ssh/proba*
-rw------- 1 ophil ophil 399 Apr 25 16:59 /home/ophil/.ssh/proba
-rw-r--r-- 1 ophil ophil  94 Apr 25 16:59 /home/ophil/.ssh/proba.pub
$ tree /home/ophil/.config/containers/podman/
/home/ophil/.config/containers/podman/
└── machine
    └── qemu
        ├── proba.ign
        ├── proba.json
        └── proba.lock

3 directories, 3 files
$ tree /home/ophil/.local/share/containers/podman
/home/ophil/.local/share/containers/podman
└── machine
    └── qemu
        ├── cache
        │   └── fedora-coreos-42.20250410.2.1-qemu.x86_64.qcow2.xz
        ├── podman.sock
        └── proba_fedora-coreos-42.20250410.2.1-qemu.x86_64.qcow2

4 directories, 3 files

		

Во-вторых, это полноценная ВМ fedora coreos:

			$ podman machine ssh proba head /etc/os-release
NAME="Fedora Linux"
VERSION="42.20250410.2.1 (CoreOS)"
RELEASE_TYPE=stable
ID=fedora
VERSION_ID=42
VERSION_CODENAME=""
PLATFORM_ID="platform:f42"
PRETTY_NAME="Fedora CoreOS 42.20250410.2.1"
ANSI_COLOR="0;38;2;60;110;180"
LOGO=fedora-logo-icon
$ podman machine ssh proba sudo dmesg|head -2
[    0.000000] Linux version 6.14.0-63.fc42.x86_64 (mockbuild@d5701c6d040c430c8283c8c9847dc93f) (gcc (GCC) 15.0.1 20250228 (Red Hat 15.0.1-0), GNU ld version 2.44-3.fc42) #1 SMP PREEMPT_DYNAMIC Mon Mar 24 19:53:37 UTC 2025
[    0.000000] Command line: BOOT_IMAGE=(hd0,gpt3)/boot/ostree/fedora-coreos-5d67a1bf86573c8e67b45ca2e1c1bde3a618c688ea15e69053c8b88695b1a8e4/vmlinuz-6.14.0-63.fc42.x86_64 rw mitigations=auto,nosmt ostree=/ostree/boot.1/fedora-coreos/5d67a1bf86573c8e67b45ca2e1c1bde3a618c688ea15e69053c8b88695b1a8e4/0 ignition.platform.id=qemu console=tty0 console=ttyS0,115200n8 root=UUID=297242c0-0f78-47fe-9161-0ac28761bdc0 rw rootflags=prjquota boot=UUID=553bd753-07f8-4dd3-b3bd-5c0605bdae3f

		

Конечно, приятно, что моё мнение совпало с мнением авторов подмана, точнее, со стратегией RedHat — fedora coreos наиболее подходящая система для контейнерных приложений. С другой стороны, ВМ в подмане крутится полностью внутри пространства пользователя. У меня уже настроена почти такая же для удалённой работы всей команды разрабов. Решено: останавливаем новую виртуалку и правим мейкфайл для подмана по образцу компоуза, делаем пайплайн для деплоя и локально, и на удалённый дев-сервер.

Но прежде нам понадобится ещё один хак #2. Если в случае докера переключение контекста можно было сделать любой переменной, то для подмана между локальным соединением через сокет и удалённым, через uri:ssh, имя переменной фиксировано CONTAINER_HOST. Вот как выглядит пайплайн make_pods.v1, настроенный и для локальной сборки, и для деплоя в наш дев-сервер:

			... $ nl make_pods.v1 
     1  .PHONY: help up back commit test clean down logs ps stats shell
     2  vshell = /bin/sh -c
     3  vhost = localhost
     4  workdir = ${HOME}/src/traefik-golang
     5  ifdef CONTAINER_HOST
     6    vshell = ssh dev@fc42dev
     7    vhost = fc42dev
     8    workdir = /home/dev/src/traefik-golang
     9    endif
    10  help:
    11          @echo "make sure you are in proper podman system connection : ${CONTAINER_HOST}\n"
    12          podman system connection list
    13          @echo "make_pods avalable targets:\n"
    14          @make -pRrq 2>/dev/null|grep -v ^Makefile|grep -P '^[\w-]+:'|sort
    15          @echo "make vars: ${vshell} ${vhost} ${workdir}"
    16  back: main.go make_pods
    17          podman build . -t backend
    18  down:
    19          ${vshell} 'cd ${workdir} ; envsubst < kube.yaml | podman kube down -'
    20  up: down
    21          ${vshell} 'cd ${workdir} ; envsubst < kube.yaml | podman kube play -'
    22  test:
    23          curl ${vhost}:8080
    24  clean:
    25          -rm back
    26          podman system prune -f
    27  commit: test
    28          git branch -v
    29          git add .
    30          @read -p "Enter commit message:" mesg;\
    31          git commit -m "$$mesg"
    32          git push
    33  logs:
    34          podman pod logs go-app
    35  ps:
    36          podman ps -ap
    37  stats:
    38          podman stats --no-stream
    39  shell:
    40          podman exec -ti go-app-traefik /bin/sh

		

По большей части цели мейкфайла остались теми же, но для удалённого деплоя настраиваем переменную export CONTAINER_HOST=ssh://dev@fc42dev:22/run/user/1001/podman/podman.sock — берём её из коннекшена, она служит переключателем между локальным и удалённым контекстом. Для локального контекста эту переменную надо удалить: unset CONTAINER_HOST. Команды в строках 19, 21 и 23 — это обычные команды шелла, они также меняются на локальное либо удалённое исполнение, переопределяются на основе этой же переменной CONTAINER_HOST.

Как заметил внимательный читатель, в цели back исчезла сборка контейнера утилитой buildah. Как и для docker compose, используется возможность самого подмана создавать образы на основе Containerfile, он же Dockerfile, эти названия синонимичны. Это намёк: пора отвыкать от слова докер, контейнеры уже давно стали основой облачных вычислений, для них созданы сотни приложений, например, CNCF и общепризнанные стандарты.

Принципиальный вопрос о контейнерах

Основное их преимущество — новый способ доставки приложений в облака, решение проблем с зависимостями, версиями библиотек, фреймворков и проч. Сборка контейнеров в контейнерах — побочный эффект облачных сервисов Github, Gitlab и других, с одной стороны, и ограничения Docker — с другой. Он не умеет, в отличие от подмана, точнее, от его сопутствующей утилиты buildah, выполнять билд и создавать образ, используя локальное окружение.

Основная проблема сборки образа внутри контейнера — неэффективное использование кэша. Да, появились возможности как-то сохранять объемные загрузки внешних библиотек, модулей: это опции --mount=type=cache для некоторых языков. Но, во-первых, эти возможности используются далеко не всегда. Во-вторых, опции для кэширования отличаются в podman и buildah, см. podman-build(1), придётся делать отдельный Containerfile. В-третьих, эффект от такого кэширования минимален. Предлагаю замерить время сборки, сделав ещё одну, третью версию пайплайна. Сначала соберём команды для buildah в отдельный файл:

			... $ nl buildah.cmd 
     1  cd $HOME/src/traefik-golang
     2  buildah rm backend
     3  buildah from --name backend scratch
     4  CGO_ENABLED='0' go build -v -ldflags "-w -s" -o back main.go
     5  buildah copy backend back /usr/local/bin/backend
     6  buildah config --entrypoint '["/usr/local/bin/backend"]' backend
     7  buildah commit backend backend:latest

		

и поправим пару строк в пайплайне:

			... $ diff make_pods.v{1,2}
16,17c16,17
< back: main.go make_pods.v1
<       podman build . -t backend
---
> back: main.go make_pods.v2 buildah.cmd
>       ${vshell} 'cd ${workdir} ; . ./buildah.cmd'

		

Уточню условия нашего эксперимента — мы настроили одинаковую среду разработки с помощью утилиты mise (предыдущая статья) на нашем дев-сервере и у каждого из разрабов команды. Репозитарий git использует этот же дев-сервер, доступ к репо и серверу по ключу, парольный доступ закрыт. Пайплайны настроены как для локальной сборки, так и на дев-сервере. Перед запуском 3-й версии пайплайна на дев-сервере нужно сделать коммит изменений в репо — buildah не знает о коннекшенах, работает с кодом в текущем каталоге (строка 1): после логина на сервер переключается в корень проекта. Предварительно выкачиваем образ компилятора go для сборки в контейнере — это вполне честно, мы же выкачали и настроили компилятор golang заранее. Замеряем:

			rm Makefile ;ln -s make_pods.v1 Makefile
time make back
...
real    0m15.328s user  0m28.341s sys   0m6.315s

rm Makefile ;ln -s make_pods.v2 Makefile
time make back
...
real    0m1.301s user   0m0.754s sys    0m0.917s

rm Makefile ;ln -s make_compose Makefile
time make build
...
real	0m13.429s user	0m0.177s sys	0m0.142s
		

Мы получили 10+-кратный выигрыш по времени сборки образа для подмана. Абсолютные времена не важны, также не влияет, запускали мы сборку локально или на дев-сервере — мы сравниваем только билд в контейнере и в настроенном локальном окружении. Третье измеренное время — сборка в Docker. Он умеет собирать только в контейнере, для него настроили кэширование в Containerfile:

			$ grep ^RUN Containerfile 
RUN --mount=type=cache,target=/go/pkg/mod CGO_ENABLED=0 go build -v -ldflags "-w -s" -o backend main.go

		

Но оно не сильно помогло. Конечно, наш проект игрушечный, golang кэширует лучше других языков, но в целом вывод понятен: сборка в контейнере далеко не оптимальный вариант, если есть возможность настроить дев-сервер для работы команды.

Ещё замечание: конечно, образы, собираемые buildah, совместимы с Docker, их можно использовать в конфигах compose.yaml. Но для этого надо настроить репозиторий образов и сначала загрузить образ в него. Локальные репозитории отличаются: Docker использует общий репо для всех пользователей — Docker Root Dir: /var/lib/docker, а в подмане всё хранится в домашнем каталоге пользователя — graphRoot: /home/$USER/.local/share/containers/storage.

Как я предположил в самом начале, мы попробовали вариант с гипотетической идеальной командой разрабов, работающей в Линукс и умеющей в make. А как быть обычному девопсу с разношерстой командой, где кто-то сидит на Винде, а кто-то ни за что не откажется от привычного Макбука на M4? Есть вариант и для этого случая. Пусть они пишут код и тестируют его как им нравится, а в нашем репо на дев-сервере мы сделаем хак #3, а именно git hook и bare репозиторий — githooks(5), выполняющий наши цели сборки и старта приложения при коммите в репо.

Для этого на пару минут придётся стать безжалостным хакером, удаляющим лишнее и открывающим скрытые возможности гита. Выполняем следующие шаги:

  1. Заходим под юзером dev на сервер, создадим пустой каталог, например, mkdir -pv ~/bare/t0. Это станет новым GIT_DIR, зайдём в него и выполним cd ~/bare/t0;git init --bare
  2. Видим, что файлы, обычно спрятанные в каталоге .git, лежат прямо в корне. Сделаем дополнительно каталог для логов mkdir logs. Переходим в каталог hooks и создаём файл, где укажем команды выполнения при каждом изменении в репо.
			... $ nl hooks/post-receive 
     1  #!/bin/sh
     2  #  local git build
     3  # set -x
     4  WORK_TREE="/var/home/dev/src/traefik-golang"
     5  GIT_DIR="/var/home/dev/bare/t0"
     6  while read oldrev newrev ref
     7  do
     8    # echo "oldrev newrev ref:" $oldrev $newrev $ref
     9    # pwd;env|sort;ls -l $WORK_TREE
    10    x_br=$(echo $ref|cut -d/ -f3)
    11    git --work-tree=$WORK_TREE --git-dir=$GIT_DIR checkout -f $x_br
    12    LOGFILE=$(date +%y%m%d_%H%M).${x_br}.log
    13    (cd $WORK_TREE && make back && make up && make test && make clean)|tee -a logs/$LOGFILE
    14  done

		

Закомментированные строки 3, 8, 9 полезны при отладке пайплайна. Строки 4 и 5 задают, что есть, собственно, репозиторий, переменная GIT_DIR и переменная WORK_TREE (куда будут записываться файлы проекта). В цикле от строки 6 до 14 читаются и обрабатываются три переменные, с которыми гит вызывает этот хук. Строка 11 принимает все изменения в репо и обновляет WORK_TREE — всё то, что гит обычно делает в общем каталоге, как видим, в bare репо они разные. Далее, в 12 строим имя лога и строка 13 — собственно, пайплайн.

  1. Идём в каталог, где расположен репо проекта. Без страха и сожаления удаляем старый и создаём новый под тем же именем: cd ~/src;rm -rf traefik-golang;mkdir traefik-golang.
  2. Завершаем сессию на дев-сервере, возвращаемся на рабочий комп и заходим в репо проекта. Конечно, репо цел, клоны репо не так просто уничтожить, пока есть хотя бы одна копия. Теперь смотрим старые настройки git remote -v и удаляем их git remote remove fc42dev в моём случае. Создаём новый remote, указывая новый гит bare репо: git remote add bare.t0 dev@fc42dev:~/bare/t0. Это также нужно сделать всем разрабам в их локальных копиях.
  3. Проверяем результат. Возможно, нужно сделать новый комит и push в новый remote. Стоит посмотреть подробнее, как изменился репо проекта на сервере: проверить логи в ~/bare/t0/logs, сравнить конфиги обычного репо проекта и на сервере, проверить, какие команды перестали работать в серверном репо. Например, в WORK_TREE не работают команды гит status; branch; commit; log. То есть наш хак #3 с git --bare не только позволил делать деплой на сервере, но также защитил репо от локальных изменений, а серверный репо всегда в чистоте и порядке. Можно редактировать код, но закомитить его только через обычный репо. Изменения на сервере удалятся после любого коммита.

Надеюсь, мне удалось показать, что пайплайны можно делать на основе древней забытой утилиты make. В следующей статье разберём, как можно добавить в наш скромный дев-сервер нечто похожее на монстров гит-сервисов, Gitlab и Github, создавать пайплайны, совместимые с github Actions, предоставить команде разрабов привычный интерфейс репо в браузере.

Следите за новыми постами
Следите за новыми постами по любимым темам
2К открытий6К показов