Как делать11 октября 2026 г.12 мин чтения
Docker и деплойШаг 8 из 8

CI/CD на GitHub Actions: проверки на каждый pull request

Коротко

CI на GitHub Actions — это YAML-файл в .github/workflows/, который на каждый pull request ставит зависимости через npm ci, запускает линтер, проверку типов, тесты и сборку на чистой машине GitHub. Чтобы сломанный код не попал в main, итоговую проверку делают обязательной в защите ветки, а публикацию и деплой выносят в отдельный workflow по тегу — с минимальными правами токена, actions, закреплёнными по SHA, и секретами окружения production.

Пошаговый урокУровень: СреднийCI/CDПрактика: ~1,5–3 ч

После материала вы сможете

  • написать ci.yml для Node.js-проекта: npm ci с кэшем, линтер, проверка типов, unit-тесты в матрице версий Node.js, интеграционные тесты с PostgreSQL в сервисном контейнере и сборка Docker-образа
  • ограничить права GITHUB_TOKEN, закрепить actions по SHA и передавать недоверенный ввод в run только через переменные окружения
  • сделать одну итоговую проверку обязательной для слияния и объяснить, почему пропущенное задание не блокирует pull request
  • проверить workflow до push через actionlint и act и настроить публикацию образа и деплой по тегу через environment

Проверено на

GitHub Actions (runner ubuntu-24.04)actions/checkout 7.0.1actions/setup-node 7.1.0actions/upload-artifact 7.0.2docker/build-push-action 7.4.0Node.js 22 / 24 / 26actionlint 1.7.12act 0.2.89

Сверено с документацией: 11 октября 2026 г.

Содержание
  1. 01CI и CD: что проверять автоматически
  2. 02Как устроен workflow: события, jobs, steps
  3. 03Проверки на pull request: Node.js, кэш, матрица, PostgreSQL
  4. 04Безопасность workflow: права, версии actions, недоверенный ввод
  5. 05Проверка до push: actionlint и локальный прогон через act
  6. 06Обязательные проверки: защита ветки
  7. 07Публикация образа и деплой по тегу
  8. 08Упражнения

CI и CD: что проверять автоматически

CI (continuous integration, непрерывная интеграция) — практика, при которой каждое изменение автоматически собирается и проверяется до слияния в основную ветку. CD (continuous delivery/deployment) — следующий шаг: проверенная версия так же автоматически публикуется и выкатывается на сервер. В GitHub Actions и то и другое описывается YAML-файлами в каталоге .github/workflows/ самого репозитория — конвейер живёт рядом с кодом и меняется через тот же pull request.

Практика урока — API заявок из материала о тестировании backend: Node.js, Express, PostgreSQL, unit- и интеграционные тесты на Vitest. Мы поставим его на конвейер: каждый pull request проходит линтер, проверку типов, unit-тесты на трёх версиях Node.js, интеграционные тесты с настоящим PostgreSQL и сборку Docker-образа, а тег вида v1.2.0 публикует образ и разворачивает его в production.

Pull requestЛинтер и типыТестыDocker-образДеплой по тегу
CI — каждый pull requestCD
Конвейер урока: четыре этапа CI на каждый pull request и деплой только по тегу версии.

pull_request

Что запускается
ci.yml: все проверки, образ собирается без публикации
Зачем
не пустить в main код, который не проходит проверки

push в main

Что запускается
ci.yml
Зачем
проверить результат слияния и обновить кэш ветки main, которым пользуются все pull request

тег v*.*.*

Что запускается
release.yml: CI → публикация образа → деплой
Зачем
выпустить версию тем же проверенным способом, а не руками с ноутбука

Как устроен workflow: события, jobs, steps

Workflow — один YAML-файл. Ключ on перечисляет события, на которые он реагирует, jobs — задания. Каждое задание GitHub запускает на отдельной чистой виртуальной машине — раннере (runner), которую выбирает runs-on. Задания идут параллельно, если их не связать через needs. Внутри задания по порядку выполняются шаги (steps): либо команда оболочки run, либо готовое действие (action) через uses — например, actions/checkout скачивает код репозитория.

Событие (on)

Что это
что запускает workflow
В нашем ci.yml
pull_request, push в main, workflow_call — вызов из другого workflow

Задание (job)

Что это
набор шагов на одной машине; результат — отдельная проверка в pull request
В нашем ci.yml
checks, unit, integration, image, ci-ok

Раннер

Что это
машина, где выполняется задание
В нашем ci.yml
ubuntu-24.04 — образ Ubuntu от GitHub с Node.js и Docker

Action

Что это
готовый переиспользуемый шаг из другого репозитория
В нашем ci.yml
actions/setup-node ставит Node.js и кэширует npm

Матрица

Что это
одно задание, размноженное по набору значений
В нашем ci.yml
node: [22, 24, 26] — три задания unit-тестов

Сервисный контейнер

Что это
Docker-контейнер, который живёт рядом с заданием
В нашем ci.yml
postgres:17-alpine для интеграционных тестов

Две особенности события pull_request, о которых стоит знать заранее. Во-первых, проверяется не последняя версия ветки, а результат её слияния с целевой веткой: GITHUB_REF равен refs/pull/<номер>/merge, и actions/checkout скачивает именно этот merge-коммит. Во-вторых, если в pull request конфликт слияния, workflow на pull_request не запускается вообще — сначала нужно разрешить конфликт. По умолчанию событие срабатывает на открытие, переоткрытие и новые коммиты в ветке (opened, reopened, synchronize).

Проверки на pull request: Node.js, кэш, матрица, PostgreSQL

Workflow должен вызывать те же команды, что разработчик запускает локально, — тогда «у меня работает» и «в CI работает» означают одно и то же. Поэтому начинаем со скриптов в package.json:

package.json
{
  "scripts": {
    "lint": "oxlint --deny-warnings src tests",
    "typecheck": "tsc -p .",
    "test:unit": "vitest run --project unit",
    "test:integration": "vitest run --project integration",
    "test": "vitest run"
  }
}
Фрагмент: раздел scripts. Зависимости — как в уроке о тестировании backend, плюс oxlint в devDependencies

Для линтера взят oxlint: проект использует TypeScript 7.0, а typescript-eslint 8.71 на момент проверки объявляет совместимость только с typescript <6.1.0. На TypeScript 5.x подойдёт привычная связка ESLint и typescript-eslint — для CI разницы нет, шаг всё равно вызывает npm run lint. Сам workflow:

.github/workflows/ci.yml
name: CI

on:
  pull_request:
  push:
    branches: [main]
  workflow_call:

# Least privilege: the token can only read the code
permissions:
  contents: read

# A new push to the same PR cancels the previous, now useless run
concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
  checks:
    name: Lint and typecheck
    runs-on: ubuntu-24.04
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false
      - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
        with:
          node-version: 24
          cache: npm
      - run: npm ci
      - run: npm run lint
      - run: npm run typecheck

  unit:
    name: Unit tests (Node ${{ matrix.node }})
    runs-on: ubuntu-24.04
    timeout-minutes: 10
    strategy:
      fail-fast: false
      matrix:
        node: [22, 24, 26]
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false
      - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
        with:
          node-version: ${{ matrix.node }}
          cache: npm
      # Node 22 ships npm 10, which rejects a lockfile written by npm 11
      - run: npm install --global npm@11
      - run: npm ci
      - run: npm run test:unit

  integration:
    name: Integration tests
    runs-on: ubuntu-24.04
    timeout-minutes: 15
    services:
      postgres:
        image: postgres:17-alpine
        env:
          POSTGRES_PASSWORD: test # a throwaway database: not a secret
          POSTGRES_DB: leads_test
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 5s
          --health-timeout 5s
          --health-retries 10
    env:
      TEST_DATABASE_URL: postgres://postgres:test@localhost:5432/leads_test
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false
      - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
        with:
          node-version: 24
          cache: npm
      - run: npm ci
      - run: npm run test:integration -- --reporter=default --reporter=junit --outputFile.junit=reports/integration.xml
      - name: Upload test report
        if: ${{ !cancelled() }}
        uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
        with:
          name: integration-report
          path: reports/
          retention-days: 7

  image:
    name: Docker image
    needs: [checks, unit, integration]
    runs-on: ubuntu-24.04
    timeout-minutes: 15
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false
      - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
      - uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
        with:
          context: .
          push: false
          tags: leads-api:ci

  # The single check to mark as required in branch protection
  ci-ok:
    name: CI passed
    if: ${{ always() }}
    needs: [checks, unit, integration, image]
    runs-on: ubuntu-24.04
    steps:
      - name: Fail if any job failed or was cancelled
        if: ${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}
        run: exit 1
Полный CI: проверено actionlint 1.7.12 и локальным прогоном через act 0.2.89
  • npm ci ставит пакеты строго по package-lock.json и падает, если lock-файл не совпадает с package.json. В CI это то, что нужно: собирается ровно то, что проверено локально.
  • cache: npm в setup-node сохраняет глобальный кэш npm (~/.npm), а не node_modules; ключ кэша — хэш package-lock.json. Пакеты не скачиваются из интернета заново, но npm ci всё равно собирает node_modules с нуля.
  • Кэш, созданный в pull request, привязан к его merge-ref и доступен только перезапускам этого же pull request. Поэтому CI запускается и на push в main: кэш ветки main могут восстановить все pull request в неё.
  • Матрица node: [22, 24, 26] создаёт три задания unit. По графику Node.js 22 поддерживается до 30 апреля 2027 года, 24 — LTS, 26 становится LTS 28 октября 2026 года. fail-fast по умолчанию true — при падении одной версии остальные отменяются; с false вы видите результат по всем версиям сразу.
  • Линтер и интеграционные тесты в матрицу не включены: их результат от версии Node.js почти не зависит, а время раннера стоит денег и минут лимита.
  • services.postgres поднимает PostgreSQL 17 рядом с заданием. Health-check (pg_isready) задерживает шаги, пока база не готова, ports пробрасывает порт на раннер — поэтому адрес localhost:5432. TEST_DATABASE_URL направляет global-setup.ts из урока о тестировании на эту базу вместо Testcontainers. Пароль test — не секрет: база живёт несколько минут и недоступна извне.
  • Отчёт JUnit загружается как artifact с условием !cancelled(): и при упавших тестах — именно тогда он нужен, — но не при отмене прогона. retention-days: 7 вместо 90 дней по умолчанию экономит хранилище.
  • concurrency с cancel-in-progress только для pull_request: новый push в ветку отменяет устаревший прогон, а прогоны на main доводятся до конца. timeout-minutes важен: по умолчанию зависшее задание живёт 360 минут.

Строка npm install --global npm@11 в матрице появилась после первого же локального прогона. Lock-файл записан npm 11, а Node.js 22 поставляется с npm 10, который считает такой lock-файл несогласованным: в проекте msw 3, а необязательная peer-зависимость @vitest/mocker ожидает msw 2. На Node.js 24 и 26 тот же npm ci проходит. Реальный вывод задания на Node.js 22 до исправления:

terminal
[CI/Unit tests (Node 22)-1] ⭐ Run Main npm ci
[CI/Unit tests (Node 22)-1]   | npm error code EUSAGE
[CI/Unit tests (Node 22)-1]   | npm error `npm ci` can only install packages when your package.json and package-lock.json or npm-shrinkwrap.json are in sync. Please update your lock file with `npm install` before continuing.
[CI/Unit tests (Node 22)-1]   | npm error Missing: msw@2.15.0 from lock file
[CI/Unit tests (Node 22)-1]   | npm error Missing: @mswjs/interceptors@0.41.9 from lock file
…
[CI/Unit tests (Node 22)-1]   ❌  Failure - Main npm ci [4.8119949s]
[CI/Unit tests (Node 22)-1] 🏁  Job failed
…
[CI/Unit tests (Node 24)-2]   ✅  Success - Main npm run test:unit [1.3380536s]
[CI/Unit tests (Node 26)-3]   ✅  Success - Main npm run test:unit [671.0905ms]
Реальный вывод act (сокращён на месте …): матрица ловит различия не только в Node.js, но и во встроенном npm

Безопасность workflow: права, версии actions, недоверенный ввод

Workflow на pull_request выполняет код из этого pull request: всё, к чему у workflow есть доступ, доступно и этому коду. Поэтому правила из руководства GitHub «Secure use reference» стоит применять с первого файла, а не «когда-нибудь потом».

  1. Минимальные права токена

    GitHub выдаёт каждому заданию GITHUB_TOKEN. permissions: contents: read на уровне workflow оставляет только чтение кода: если указано хотя бы одно право, все неуказанные становятся none. Расширяйте права точечно в задании — в release.yml packages: write есть только у задания публикации.

  2. Actions по полному SHA

    Тег вроде @v7 владелец репозитория action может передвинуть, и если репозиторий взломают, новая версия незаметно придёт во все workflow. Полный SHA коммита, по документации GitHub, — единственный способ сослаться на неизменяемую версию. Комментарий # v7.0.1 — для людей. Чтобы закреплённые версии не устаревали, GitHub рекомендует Dependabot с package-ecosystem: github-actions — он следит за ссылками на actions в workflow. Берите SHA из репозитория самого action, а не из форка.

  3. Без лишних учётных данных

    actions/checkout по умолчанию сохраняет токен, чтобы следующие шаги могли выполнять git-команды. Нашему CI git push не нужен, поэтому persist-credentials: false.

  4. Недоверенный ввод — только через env

    Заголовок pull request, имя ветки, текст комментария пишет автор pull request. Выражение ${{ … }} подставляется в скрипт до запуска оболочки, поэтому заголовок с кавычками и ; становится командой. Передавайте такие значения через env и используйте как обычную переменную.

  5. Осторожно с привилегированными событиями

    pull_request_target и workflow_run выполняются в контексте базового репозитория — с секретами и правом записи. Не скачивайте и не запускайте в них код из pull request; actions/checkout начиная с v7 по умолчанию отказывается это делать.

.github/workflows/pr-title.yml
name: PR title

on:
  pull_request:
    types: [opened, edited, synchronize, reopened]

permissions: {}

jobs:
  title:
    name: PR title format
    runs-on: ubuntu-24.04
    steps:
      - name: Check PR title
        env:
          TITLE: ${{ github.event.pull_request.title }} # untrusted input: only via env
        run: |
          pattern='^(feat|fix|docs|chore): '
          if [[ ! "$TITLE" =~ $pattern ]]; then
            echo "PR title must start with feat:, fix:, docs: or chore:"
            exit 1
          fi
          echo "PR title is OK"
Проверка формата заголовка: заголовок попадает в скрипт как переменная TITLE, а не как текст скрипта. permissions: {} — токену не нужно ничего

Мы прогнали этот workflow через act с заголовком a"; touch /tmp/pwned; echo ": проверка формата не прошла, а команда из заголовка не выполнилась — файла /tmp/pwned в контейнере нет. В контрольном варианте с ${{ github.event.pull_request.title }} прямо в run тот же заголовок создал файл. Секреты из Settings → Secrets and variables → Actions (или gh secret set NAME) читаются как ${{ secrets.NAME }}. Важно для CI: workflow, запущенный pull request из форка, секретов не получает (кроме GITHUB_TOKEN с правами только на чтение), а прогоны pull request от Dependabot работают как форки. Значит, проверки pull request должны обходиться без секретов — как наш ci.yml. Как хранить и ротировать секреты, разобрано в материале о секретах и переменных окружения.

Проверка до push: actionlint и локальный прогон через act

Ошибку в YAML GitHub покажет только после push — и пока вы её чините, история ветки обрастает коммитами «fix ci». actionlint — статический анализатор workflow: проверяет синтаксис, выражения ${{ }}, ссылки needs, типы контекстов, известные ловушки безопасности и прогоняет скрипты run через shellcheck. Проще всего запускать его в Docker из корня репозитория:

bash
docker run --rm -v "$PWD:/repo" -w /repo rhysd/actionlint:1.7.12
actionlint проверяет все файлы в .github/workflows; пустой вывод и код выхода 0 — ошибок нет

Вот что он находит в workflow с тремя типичными ошибками — опечаткой в имени переменной матрицы, заголовком pull request прямо в скрипте и ссылкой на несуществующее задание:

.github/workflows/ci.yml
name: CI

on: pull_request

jobs:
  unit:
    runs-on: ubuntu-24.04
    strategy:
      matrix:
        node: [22, 24]
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: ${{ matrix.nodes }}
      - run: echo "Testing ${{ github.event.pull_request.title }}"
      - run: npm ci && npm test

  image:
    needs: [checks]
    runs-on: ubuntu-24.04
    steps:
      - run: docker build .
Намеренно сломанный workflow — не копируйте его
terminal
$ docker run --rm -v "$PWD:/repo" -w /repo rhysd/actionlint:1.7.12
.github/workflows/ci.yml:15:29: property "nodes" is not defined in object type {node: number} [expression]
   |
15 |           node-version: ${{ matrix.nodes }}
   |                             ^~~~~~~~~~~~
.github/workflows/ci.yml:16:32: "github.event.pull_request.title" is potentially untrusted. avoid using it directly in inline scripts. instead, pass it through an environment variable. see https://docs.github.com/en/actions/reference/security/secure-use#good-practices-for-mitigating-script-injection-attacks for more details [expression]
   |
16 |       - run: echo "Testing ${{ github.event.pull_request.title }}"
   |                                ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
.github/workflows/ci.yml:19:3: job "image" needs job "checks" which does not exist in this workflow [job-needs]
   |
19 |   image:
   |   ^~~~~~
$ echo $?
1
Реальный вывод actionlint 1.7.12: все три ошибки найдены до push

actionlint не запускает задания. Для этого есть act — он читает те же файлы и выполняет задания в Docker-контейнерах, имитирующих раннер GitHub. Мы прогнали ci.yml на событии pull_request с образом catthehacker/ubuntu:act-24.04:

bash
act pull_request -e pr-event.json \
  -P ubuntu-24.04=catthehacker/ubuntu:act-24.04 \
  --artifact-server-path /tmp/artifacts
pr-event.json — минимальное тело события: номер, заголовок, head и base pull request
terminal
$ act pull_request -e pr-event.json \
    -P ubuntu-24.04=catthehacker/ubuntu:act-24.04 \
    --artifact-server-path /tmp/artifacts
…
[CI/Integration tests     ] container health of 966c061c5bbf… (postgres:17-alpine) is healthy
…
[CI/Lint and typecheck    ]   ✅  Success - Main npm run lint [1.144679915s]
[CI/Lint and typecheck    ]   ✅  Success - Main npm run typecheck [1.023296871s]
[CI/Lint and typecheck    ] 🏁  Job succeeded
[CI/Unit tests (Node 22)-1]   |       Tests  9 passed (9)
[CI/Unit tests (Node 22)-1] 🏁  Job succeeded
[CI/Unit tests (Node 26)-3] 🏁  Job succeeded
[CI/Unit tests (Node 24)-2] 🏁  Job succeeded
[CI/Integration tests     ]   |       Tests  10 passed (10)
[CI/Integration tests     ]   | Artifact integration-report has been successfully uploaded! Final size is 750 bytes. Artifact ID is 3500521754
[CI/Integration tests     ] 🏁  Job succeeded
[CI/Docker image          ]   ✅  Success - Main docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc [19.041554739s]
[CI/Docker image          ] 🏁  Job succeeded
[CI/CI passed             ] 🏁  Job succeeded
Реальный вывод act 0.2.89 (сокращён на месте …): все задания зелёные, отчёт тестов загружен как artifact

синтаксис, выражения, needs, shellcheck

Где проверяется
actionlint — локально и в CI

шаги, матрица, npm ci, тесты, сервисный PostgreSQL, сборка образа, artifact

Где проверяется
act — локально, в Docker (с оговорками ниже)

права GITHUB_TOKEN, секреты, environment, защита ветки, публикация в ghcr.io

Где проверяется
только на GitHub: act подставляет свой токен и не знает настроек репозитория

Обязательные проверки: защита ветки

Сам по себе CI только сообщает результат: красный крестик не мешает нажать Merge. Блокирует слияние правило защиты ветки или ruleset с настройкой «Require status checks to pass before merging» — что ещё входит в защиту ветки, разобрано в материале о pull request. Вопрос в том, какие проверки сделать обязательными.

Каждое задание — отдельная проверка, у матрицы — по проверке на версию: Unit tests (Node 22), Unit tests (Node 24) и так далее. Перечислять их все неудобно: при добавлении версии в матрицу придётся менять настройки репозитория. Хуже другое: по документации GitHub, задание, зависящее от упавшего, пропускается, а статус пропущенного задания считается успешным — то есть обязательная проверка Docker image после падения тестов может не заблокировать слияние.

Поэтому в ci.yml есть итоговое задание ci-ok с именем CI passed. Оно зависит от всех остальных, выполняется всегда (if: ${{ always() }}) и падает, если хотя бы одно задание завершилось с failure или было отменено. Обязательной делается только эта проверка.

  • Не вешайте на обязательный workflow фильтры paths и branches: если workflow пропущен фильтром, его проверки остаются в состоянии «Pending» и блокируют слияние навсегда.
  • Имена заданий должны быть уникальны во всех workflow репозитория — одинаковые имена дают неоднозначный статус проверки.
  • Строгий режим «Require branches to be up to date before merging» включён по умолчанию: ветку нужно обновить от base, и проверки идут заново. При большом потоке pull request ту же гарантию без ручного обновления даёт merge queue.

Публикация образа и деплой по тегу

Деплой запускается тегом версии, а не каждым слиянием: так в production попадает осознанно выпущенная версия, и всегда видно, какая именно. Образ собирается по Dockerfile из урока об основах Docker:

Dockerfile
FROM node:24-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY src ./src
USER node
EXPOSE 3000
CMD ["node", "src/server.ts"]
Образ API: только production-зависимости, процесс от пользователя node. Node.js 24 исполняет .ts напрямую
.github/workflows/release.yml
name: Release

on:
  push:
    tags: ['v*.*.*']

permissions:
  contents: read

# Never run two deployments at once and never cancel one halfway
concurrency:
  group: release-production
  cancel-in-progress: false

jobs:
  ci:
    name: CI
    uses: ./.github/workflows/ci.yml

  publish:
    name: Publish image
    needs: ci
    runs-on: ubuntu-24.04
    timeout-minutes: 15
    permissions:
      contents: read
      packages: write # push to ghcr.io with GITHUB_TOKEN
    outputs:
      digest: ${{ steps.push.outputs.digest }}
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false
      - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
      - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - id: meta
        uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
        with:
          images: ghcr.io/${{ github.repository }}
          tags: |
            type=semver,pattern={{version}}
            type=semver,pattern={{major}}.{{minor}}
      - id: push
        uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}

  deploy:
    name: Deploy to production
    needs: publish
    runs-on: ubuntu-24.04
    timeout-minutes: 10
    # Environment secrets, required reviewers and allowed tags are set in Settings → Environments
    environment:
      name: production
      url: https://leads.example.com
    steps:
      - name: Trigger the hosting deploy hook
        env:
          DEPLOY_HOOK_URL: ${{ secrets.DEPLOY_HOOK_URL }}
          REPOSITORY: ${{ github.repository }}
          DIGEST: ${{ needs.publish.outputs.digest }}
        run: |
          image="ghcr.io/${REPOSITORY,,}@${DIGEST}"
          echo "Deploying $image"
          curl --fail-with-body --silent --show-error -X POST "$DEPLOY_HOOK_URL" \
            --data-urlencode "image=$image"
Выпуск: тот же CI, публикация образа в GitHub Container Registry и деплой в окружение production
  • uses: ./.github/workflows/ci.yml вызывает CI как переиспользуемый workflow (для этого в ci.yml есть событие workflow_call) на том же коммите, что и тег. По документации, права токена в такой цепочке можно только сохранить или сузить, но не расширить.
  • Задание publish — единственное с packages: write: GITHUB_TOKEN входит в ghcr.io, docker/metadata-action строит теги образа 1.2.0 и 1.2 из git-тега и приводит имя образа к нижнему регистру, build-push-action собирает и публикует образ и отдаёт его digest.
  • environment: production — окружение из Settings → Environments. Его секреты, например DEPLOY_HOOK_URL, доступны только заданиям, которые на него ссылаются. Там же настраиваются обязательные ревьюеры деплоя и список тегов и веток, которым разрешено в него деплоить.
  • Деплой ссылается на образ по digest (@sha256:…), а не по тегу: тег можно перезаписать, digest — нет. ${REPOSITORY,,} в bash приводит имя репозитория к нижнему регистру, как того требует registry.
  • concurrency с cancel-in-progress: false: два выпуска не идут одновременно, и начатый деплой не обрывается на середине.
bash
git switch main && git pull
git tag -a v1.2.0 -m "Lead source field"
git push origin v1.2.0
Выпуск версии: аннотированный тег на актуальном main

Упражнения

Как проверено5 · пройдено3 · частичносверено 11 октября 2026 г.
  • запущено, работает

    .github/workflows/ci.yml, release.yml, pr-title.yml и решения упражнений (coverage-фрагмент в ci.yml, staging.yml)

    actionlint 1.7.12 (docker rhysd/actionlint:1.7.12, со встроенным shellcheck 0.11.0) — 0 ошибок во всех файлах; SHA всех actions получены git ls-remote из репозиториев самих actions (lightweight-теги → коммиты)

  • запущено, работает

    находка: npm ci на Node 22 с lock-файлом npm 11

    первый прогон act без шага npm@11: Node 22 (npm 10.9.9) — EUSAGE 'Missing: msw@2.15.0 from lock file', Node 24/26 — проходит; повторено в node:22-alpine: npm 10 ci exit=1, после npm install -g npm@11 (11.21.0) — exit=0. Причина: @vitest/mocker 5.0.3 объявляет optional peer msw ^2.4.9, в проекте msw 3.0.3

  • запущено, работает

    ci-ok (CI passed) — блокировка при упавшем задании

    в прогоне act с несовместимым upload-artifact v7 задание Integration tests упало, Docker image не запускался (needs), CI passed выполнился и завершился exit 1; в первом прогоне аналогично при упавшем Node 22

  • запущено, работает

    pr-title.yml: недоверенный ввод через env

    act с событием pull_request: заголовок 'feat: lead source field' — успех; заголовок 'a"; touch /tmp/pwned; echo "' — проверка формата падает, /tmp/pwned в контейнере задания отсутствует (act --reuse + docker exec ls); контрольный workflow с ${{ github.event.pull_request.title }} прямо в run — /tmp/pwned создан. Сломанный пример из статьи — реальный вывод actionlint (3 ошибки, exit 1)

  • запущено, работает

    package.json scripts, Dockerfile

    проект из урока testirovanie-backenda + oxlint 1.87.0: npm run lint / typecheck (TypeScript 7.0.2) / test:unit на Windows и в act; docker build → образ 248 MB, запуск с фейковыми переменными (sk_test_FAKE_ci, FAKE_manager): node v24.20.0, пользователь node, GET /api/leads без токена → 401

  • частично

    ci.yml целиком — локальный прогон через act

    act 0.2.89 (Linux-бинарь, sha256 сверен с checksums.txt) внутри контейнера catthehacker/ubuntu:act-24.04 с docker.sock, образ раннера catthehacker/ubuntu:act-24.04, событие pull_request из pr-event.json: все 7 заданий зелёные (lint+typecheck; unit 9/9 на Node 22.23.3, 24.21.0, 26.11.1; integration 10/10 против сервиса postgres:17-alpine; artifact integration-report.zip c integration.xml; сборка образа build-push-action; CI passed). Отличия копии для act: порт сервиса 55439 вместо 5432 (5432 на машине занят чужим контейнером, act ставит задания в сеть host) и upload-artifact v4.6.2 вместо v7.0.2 — сервер artifacts act 0.2.89 отвечает на v7 'unknown field mime_type' (проверено отдельным workflow: v4 загружается, v7 падает). Поведение на настоящем GitHub (permissions, кэш GitHub, merge-ref) не проверялось

  • частично

    release.yml: публикация образа и деплой

    actionlint — 0 ошибок; шаг деплоя выполнен отдельно в контейнере против локального фейкового веб-хука: имя репозитория приводится к нижнему регистру (InterLab/Leads-API → interlab/leads-api), URL-кодированный image=…@sha256:… уходит в тело, ответ 403 → curl exit 22 (задание упадёт). Вход в ghcr.io, push образа, environment production и его секреты не проверялись — ничего на реальном GitHub не создавалось

  • частично

    упражнения

    repeat: debugger; в src/leads.ts → oxlint 'eslint(no-debugger)' warning, --deny-warnings → exit 1; поведение статусов needs/always подтверждено прогонами act выше. modify: @vitest/coverage-v8 5.0.3, npm run test:unit -- --coverage создаёт coverage/ (html, clover, json), фрагмент проходит actionlint; уникальность имён artifact — по README upload-artifact, не прогонялась на GitHub. apply: staging.yml проходит actionlint, не запускался

«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.

Расскажите о задаче — мы изучим запрос и свяжемся с вами

Есть задача?
Обсудим и оценим — бесплатно.

Расскажите о продукте или проблеме. Предложим архитектуру, срок и стоимость первой версии после изучения задачи.

  • Оценка объёма и рисков до старта
  • NDA — по вашему или нашему шаблону
  • Старт работ в течение недели

Не присылайте через форму пароли, ключи доступа и исходный код — безопасную передачу доступа обсудим отдельно.

Нажимая кнопку, вы соглашаетесь с обработкой персональных данных.