Увійти Реєстрація
Блог Серії
Кар'єра
Вакансії Компанії
Навчання
Документація Співбесіди Тестування Відео
Екосистема
Пакети Ресурси Проєкти Інструменти Події
Інше
Про нас Реклама

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.

Докладніше в документації: githooks

Тести, що працюють з базою, найкраще ганяти на тій самій СУБД, що й на продакшені: 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 - свідомий вибір: мінімум для швидкості, а для кроків, яким потрібна історія, - явно догрузити рівно стільки, скільки треба.

Докладніше в документації: actions/checkout

Видалити секрет з історії складно, а після публікації - вже запізно: ключ треба вважати скомпрометованим. Тому головне - не дати йому потрапити в коміт. Захист будують у кілька шарів.

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 не знає формату вашого внутрішнього токена. Разом вони ловлять переважну більшість випадків.

Докладніше в документації: GitHub: 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$) у файли при створенні архіву.

Докладніше в документації: gitattributes: export-ignore