Этот урок - про git ci cd с инженерной стороны. Разберём, почему shallow clone ci быстрый, но коварный; как кешировать .git между джобами; почему в CI ты всегда в detached HEAD и как достать имя ветки; что такое событие merge_group и зачем гонять проверки в merge queue ДО вливания; и главное - как не спалить токены в логах и уйти от долгоживущих секретов через OIDC. Всё это - фундамент trunk-based разработки, где десятки коротких веток вливаются в main по много раз в день.
fetch-depth и shallow clone: почему CI клонирует не всё
Когда ты делаешь git clone у себя, Git тащит весь граф коммитов. В CI это расточительно: репозиторий монорепы может весить гигабайты, а джобе нужен ровно один коммит - тот, что запустил пайплайн. Поэтому actions/checkout по умолчанию делает shallow clone глубиной 1. Под капотом это git fetch с флагом --depth=1.
Код: Выделить всё
$ git clone --depth=1 https://github.com/acme/app.git
$ cd app
$ git log --oneline
a1b2c3d (HEAD -> main, grafted, origin/main) fix: race in worker
$ cat .git/shallow
a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0
- git blame на shallow покажет, что чуть ли не каждую строку написал один последний коммит - потому что предков, где строки реально менялись, в репозитории нет.
- git describe не найдёт ближайший тег и либо упадёт, либо выдаст мусор - теги при depth=1 обычно не приезжают вовсе.
- git merge-base origin/main HEAD вернёт "fatal: no merge base" или "no common commits" - точки расхождения веток нет в графе. На этом ломаются почти все changed-files экшены, которые считают diff между ветками.
Код: Выделить всё
# .github/workflows/ci.yml
- uses: actions/checkout@v5
with:
fetch-depth: 0 # 0 = вся история и все теги, НЕ нулевая глубина

Кеширование .git и зависимостей между джобами
Раннер эфемерен, но клонировать гигабайты на каждый запуск глупо. Есть два механизма.
Первый - кеш зависимостей (node_modules, vendor, .m2, ~/.cargo). Это самое прибыльное: качаешь пакеты один раз, восстанавливаешь по хешу lock-файла.
Код: Выделить всё
- uses: actions/cache@v4
with:
path: |
~/.cache/composer
vendor
key: deps-${{ hashFiles('composer.lock') }}
restore-keys: deps-
Второй механизм - кеш самого .git. На GitHub Actions checkout уже умеет частично переиспользовать объекты, но на self-hosted раннерах и в git gitlab ci кеширование папки .git между пайплайнами реально ускоряет fetch: вместо полного клона Git докачивает только новые объекты. Здесь же на 2026 хорошо ложится partial clone - actions/checkout умеет filter: blob:none, и Git притащит граф коммитов без содержимого файлов, дотягивая блобы по требованию. Для джоб, которым нужна история коммитов, но не все версии всех файлов (тот же commitlint), это лучший компромисс между depth=1 и fetch-depth: 0.
Код: Выделить всё
- uses: actions/checkout@v5
with:
filter: blob:none # граф есть, blame/merge-base работают, блобы - лениво
Запусти в пайплайне git status и удивишься:
Код: Выделить всё
$ git status
HEAD detached at a1b2c3d
nothing to commit, working tree clean
$ git rev-parse --abbrev-ref HEAD
HEAD
Правильный путь - брать имя ветки не из Git, а из переменных окружения CI, который точно знает контекст события:
Код: Выделить всё
# GitHub Actions
echo "$GITHUB_REF_NAME" # feature/login (для push)
echo "$GITHUB_HEAD_REF" # ветка-источник PR (для pull_request)
# GitLab CI
echo "$CI_COMMIT_REF_NAME"
echo "$CI_MERGE_REQUEST_SOURCE_BRANCH_NAME"
Событие merge_group: проверки в merge queue до вливания
Теперь к 2026-новинке для git github actions, которая прямо завязана на trunk-based. Представь десять PR, каждый зелёный сам по себе, протестированный против main, который был час назад. Их вливают подряд - и main падает: PR номер 3 удалил функцию, которую PR номер 7 начал использовать. Каждый по отдельности валиден, вместе - нет. Это называется semantic merge conflict, и обычный required check его не ловит.
Решает это merge queue. Когда PR одобрен, он встаёт в очередь. GitHub берёт текущий main, накатывает поверх изменения из очереди и создаёт временную ветку-кандидат gh-readonly-queue/main/.... Для неё стреляет событие merge_group. CI прогоняется уже на реальной комбинации "main плюс всё, что вливается" - и только если зелено, изменения попадают в main. Красный кандидат вылетает из очереди, не сломав ствол.
Главный подвох, на котором спотыкаются все: если твой workflow триггерится только на pull_request, то в merge queue он не запустится, и required-проверка зависнет навечно. Нужно явно добавить триггер:
Код: Выделить всё
on:
pull_request:
merge_group:
types: [checks_requested]
Безопасность: токены, OIDC и git токен безопасность ci
Самая дорогая ошибка в CI - утечка секрета. И Git к ней причастен напрямую.
Не свети credentials в логах. Классика - токен в URL ремоута. Если сделать так, он окажется в выводе и осядет в архиве логов пайплайна:
Код: Выделить всё
# ПЛОХО: токен попадёт в логи при ошибке или set -x
git push https://x-access-token:${TOKEN}@github.com/acme/app.git
Минимальные права токена. GITHUB_TOKEN по умолчанию выдаётся на джобу с правами, и их надо урезать до необходимого. Тот же принцип для CI_JOB_TOKEN в GitLab.
Код: Выделить всё
permissions:
contents: read # по умолчанию - только чтение
pull-requests: write # добавляем точечно, что реально нужно
Код: Выделить всё
permissions:
id-token: write # без этого GitHub не выдаст OIDC-токен
contents: read
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/ci-deploy
aws-region: eu-central-1
Защита от инъекций. Никогда не подставляй пользовательский ввод (заголовок PR, имя ветки) прямо в shell-команду внутри Git-вызова - заголовок вида $(rm -rf /) выполнится. Прокидывай такие данные через переменные окружения, а не через интерполяцию в run.
Отключай хуки в CI. Раннер не должен исполнять хуки из репозитория - это и лишние действия, и вектор атаки. Современный безопасный дефолт - core.hooksPath, указывающий в пустоту, или флаг для одиночной команды:
Код: Выделить всё
git -c core.hooksPath=/dev/null commit -m "ci: bump version"
- fetch-depth: 1 (дефолт) плюс git describe = версия-мусор. Релизные джобы всегда с fetch-depth: 0.
- git merge-base падает с "no merge base" - забыл полную историю для diff между ветками.
- merge_group не добавлен в triggers - проверка в merge queue висит pending вечно, PR не вливается.
- Ключ кеша без хеша lock-файла - кеш протух и тащит старые зависимости, сборка зелёная на мусоре.
- Токен в git remote URL - утёк в логи. Звёздочки маскировки не спасают от перекодировки.
- Скрипт берёт ветку из git rev-parse в detached HEAD - получает строку "HEAD" вместо имени.
- Долгоживущие AWS-ключи в секретах вместо OIDC - тикающая бомба при первой же утечке.
- Возьми любой репозиторий с историей и тегами. Сделай рядом shallow-копию: git clone --depth=1 file:///path/to/repo shallow и зайди в неё.
- Глянь cat .git/shallow и git log --oneline - увидишь grafted на единственном коммите.
- Запусти git describe --tags - получишь ошибку "No names found" или "cannot describe". Запусти git merge-base HEAD HEAD~5 - получишь fatal, предков нет.
- Дотяни историю: git fetch --unshallow. Проверь, что .git/shallow исчез, а git describe и git log теперь работают.
- Повтори через partial clone: git clone --filter=blob:none file:///path/to/repo partial. Сравни du -sh у обоих и убедись, что blame и merge-base тут работают сразу.
- Почему git blame в shallow-клоне приписывает почти все строки одному коммиту и как это починить?
- Workflow с required-проверкой настроен только on: pull_request. Что произойдёт, когда PR встанет в merge queue?
- Чем OIDC-аутентификация в облако лучше хранения ключей доступа в секретах CI? Какое право в permissions для неё обязательно?
- В CI ты в detached HEAD. Откуда брать имя ветки-источника для PR на GitHub и почему не из git rev-parse?
CI - это Git в спартанских условиях: эфемерный раннер, обрезанная история, чужие права. Держи в голове три рычага. Глубина: depth=1 для сборки, fetch-depth: 0 или blob:none там, где нужна история и теги. Очередь: merge_group гоняет проверки на реальной комбинации изменений до вливания в ствол - фундамент честного trunk-based. Безопасность: минимальные права токенов, никаких секретов в логах и URL, keyless OIDC вместо долгоживущих ключей. Сложи это - и пайплайн станет быстрым, а main перестанет краснеть от того, что два валидных PR не ужились вместе.