Middle: питання на співбесіді з теми «Хуки, CI й автоматизація»
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
5 питань
Для повідомлень комітів є два хуки:
prepare-commit-msg- викликається до відкриття редактора; може підготувати чи доповнити текст;commit-msg- викликається після введення повідомлення; може перевірити його і заборонити коміт.
Обидва отримують шлях до файла з повідомленням першим аргументом.
Перевірка Conventional Commits у commit-msg:
#!/bin/sh
pattern='^(feat|fix|docs|refactor|test|chore|perf|ci|build)(\([a-z0-9-]+\))?!?: .{1,72}$'
if ! head -1 "$1" | grep -qE "$pattern"; then
echo "Повідомлення не відповідає Conventional Commits:"
echo " feat(vacancies): add CSV export"
exit 1
fi
Номер задачі з назви гілки в prepare-commit-msg:
#!/bin/sh
# гілка feature/ABC-123-csv-export -> префікс [ABC-123]
case "$2" in merge|squash|commit) exit 0 ;; esac # не чіпати merge, squash і --amend
ticket=$(git branch --show-current | grep -oE '[A-Z]+-[0-9]+')
[ -n "$ticket" ] && ! grep -q "$ticket" "$1" && sed -i.bak "1s/^/[$ticket] /" "$1" && rm -f "$1.bak"
Другий аргумент - джерело повідомлення (message при -m, merge, squash, commit при --amend чи -c), щоб не дописувати префікс у службові повідомлення.
Готові інструменти:
- commitlint (Node) - правила для Conventional Commits, часто разом з Husky;
- CaptainHook має вбудовані дії для перевірки повідомлень (довжина рядків, регулярні вирази, загальноприйняті правила оформлення повідомлень);
- GrumPHP - задача
git_commit_message.
Чому перевірка потрібна і в CI: хук локальний, і git commit --no-verify його пропускає. Якщо від формату залежать змінлог і версіонування, перевіряйте повідомлення ще й у CI - або заголовок PR, якщо зливаєте через squash: тоді саме він стає повідомленням коміту в main.
Тести, що працюють з базою, найкраще ганяти на тій самій СУБД, що й на продакшені: SQLite пропускає помилки, специфічні для PostgreSQL чи MySQL (суворіший GROUP BY, типи, регістр у LIKE).
Service containers - Docker-контейнери, які GitHub Actions піднімає поруч із job:
jobs:
tests:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:18
env:
POSTGRES_DB: testing
POSTGRES_USER: app
POSTGRES_PASSWORD: secret
ports: ["5432:5432"]
options: >-
--health-cmd pg_isready
--health-interval 5s
--health-timeout 5s
--health-retries 10
redis:
image: redis:8-alpine
ports: ["6379:6379"]
env:
DB_CONNECTION: pgsql
DB_HOST: 127.0.0.1
DB_DATABASE: testing
DB_USERNAME: app
DB_PASSWORD: secret
REDIS_HOST: 127.0.0.1
steps:
- uses: actions/checkout@v7
- uses: shivammathur/setup-php@v2
with:
php-version: '8.5'
extensions: pdo_pgsql, redis
- id: composer-cache
run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT"
- uses: actions/cache@v6
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: composer-${{ hashFiles('composer.lock') }}
restore-keys: composer-
- run: composer install --no-interaction --prefer-dist
- run: php artisan test --parallel
Ключові моменти:
- healthcheck у
options- GitHub чекає, доки база стане здоровою, перш ніж запускати кроки. Без нього тести можуть стартувати раніше, ніж PostgreSQL готовий; - адреса: job працює прямо на runner-і, тому сервіси доступні як
127.0.0.1через опубліковані порти. Якщо job сам виконується в контейнері (container:), сервіси доступні за назвою (postgres), і порти публікувати не треба; - кеш Composer за хешем
composer.lock- залежності не завантажуються щоразу; кешується каталог завантажень Composer, а неvendor/; - змінні
envперекривають.env- конфігурація тестів без окремого файла.
Прискорення: --parallel для тестів, окремі jobs для стилю (Pint) і статичного аналізу (PHPStan), щоб вони йшли паралельно з тестами, і concurrency з cancel-in-progress: true - скасовувати застарілі запуски при новому push у ту саму гілку.
Докладніше в документації: GitHub Actions: service containers
actions/checkout за замовчуванням завантажує лише один коміт - той, що запустив workflow (fetch-depth: 1). Для збирання й тестів цього досить, і це швидко. Але все, що потребує історії, ламається.
Типові симптоми:
fatal: No names found, cannot describe anything. # git describe без тегів
fatal: ambiguous argument 'origin/main...HEAD' # немає гілки main
fatal: bad object ... # немає потрібного коміту
git describeчи визначення версії з тегів - тегів немає;git diff origin/main...HEAD,pint --diff=main, «запускати тести лише для змінених файлів» - немаєmainі спільного предка;- генерація змінлогу з повідомлень комітів - немає комітів;
git logдля дати останньої зміни файла - показує один коміт;- інструменти на кшталт SonarQube скаржаться на неповну історію для blame.
Варіанти виправлення:
- uses: actions/checkout@v7
with:
fetch-depth: 0 # уся історія, усі гілки й теги
0 - найпростіше, але у великому репозиторії checkout стає помітно повільнішим.
Точніше - догрузити лише потрібне:
- uses: actions/checkout@v7
- run: git fetch --no-tags --depth=50 origin main
- run: vendor/bin/pint --test --diff=origin/main
Або fetch-tags: true з невеликою глибиною, якщо потрібні лише теги.
Нюанс pull request-ів: для події pull_request checkout за замовчуванням бере тимчасовий коміт злиття PR з базовою гілкою (refs/pull/N/merge), а не останній коміт гілки PR. Тести перевіряють результат злиття, що добре, але git log покаже коміт злиття, якого немає в гілці. Якщо потрібна саме голова гілки - ref: ${{ github.event.pull_request.head.sha }}.
Правило: глибина історії в CI - свідомий вибір: мінімум для швидкості, а для кроків, яким потрібна історія, - явно догрузити рівно стільки, скільки треба.
Видалити секрет з історії складно, а після публікації - вже запізно: ключ треба вважати скомпрометованим. Тому головне - не дати йому потрапити в коміт. Захист будують у кілька шарів.
1. Структура проєкту:
- секрети лише в
.env, який у.gitignore; у репозиторії -.env.exampleз порожніми значеннями; - у коді - лише
config('services.stripe.secret'), ніяких ключів у тестах, сидерах і фікстурах; - для CI - секрети платформи (
${{ secrets.NAME }}), а не файли в репозиторії.
2. Локальна перевірка до коміту - сканер секретів у pre-commit:
#!/bin/sh
gitleaks git --pre-commit --staged --redact --verbose
gitleaks шукає за сотнями шаблонів (ключі AWS, Stripe, GitHub-токени, приватні ключі) і за ентропією рядків. Хибні спрацювання - через .gitleaksignore чи конфігурацію.
3. Захист на сервері - push protection на GitHub:
- для користувачів увімкнено за замовчуванням: GitHub блокує ваш push із розпізнаним секретом у публічні репозиторії;
- для репозиторіїв (потрібна GitHub Secret Protection) - вмикається адміністратором і блокує push із секретами у конкретний репозиторій, зокрема з командного рядка, через вебінтерфейс і API.
При блокуванні push GitHub показує, у якому файлі й коміті знайдено секрет. Обхід можливий із зазначенням причини (хибне спрацювання, тестовий ключ), і для захисту на рівні репозиторію він створює сповіщення й запис у журналі аудиту.
4. Сканування в CI - той самий gitleaks на кожен PR: ловить те, що пройшло повз локальний хук через --no-verify чи відсутність хука.
5. Мінімізація шкоди:
- ключі з мінімальними правами й обмеженим терміном дії;
- окремі ключі для розробки, staging і продакшену;
- план дій на випадок витоку: спочатку відкликати ключ, а вже потім чистити історію.
Чому шари: кожен окремо можна обійти - хук не встановлено, push protection не знає формату вашого внутрішнього токена. Разом вони ловлять переважну більшість випадків.
Коли Composer встановлює пакет з дистрибутива (--prefer-dist, поведінка за замовчуванням для стабільних версій), він завантажує ZIP-архів, який GitHub генерує через git archive. Атрибут export-ignore у .gitattributes виключає файли з цього архіву.
# .gitattributes пакета
/.github export-ignore
/tests export-ignore
/docs export-ignore
/.gitattributes export-ignore
/.gitignore export-ignore
/phpunit.xml.dist export-ignore
/phpstan.neon.dist export-ignore
/pint.json export-ignore
/CHANGELOG.md export-ignore
Перевірити результат локально:
git archive HEAD | tar -t
Виключені файли в списку не з'являться.
Навіщо це:
- менший
vendor/у кожному проєкті, що використовує пакет: тести, документація й конфіги CI пакета там нікому не потрібні; - швидше встановлення і менші образи Docker;
- менше шуму для інструментів, що сканують
vendor/(автодоповнення IDE, статичний аналіз); - не публікувати зайве - внутрішні скрипти, фікстури з даними.
Що не можна виключати:
src/,composer.json, файли, на які посилається автозавантаження;LICENSE- ліцензія має йти разом з кодом;README.mdзазвичай лишають;- ресурси, потрібні пакету під час роботи: шаблони, конфіги, міграції, файли перекладів.
Помилка тут ламає пакет лише при встановленні з дистрибутива: локально в репозиторії все працює, тому варто перевіряти через git archive чи тестове встановлення в чистий проєкт.
Пов'язане: --prefer-source завантажує повний клон репозиторію, і export-ignore там не діє. Інший корисний атрибут - export-subst: підставляє дані коміту (наприклад, $Format:%H$) у файли при створенні архіву.