При ручном обновлении сайта разработчик каждый раз повторяет одни и те же действия: загружает изменения, устанавливает зависимости, собирает приложение и проверяет результат. Автодеплой выполняет эту последовательность по заранее настроенному сценарию. На alexeslm.ru его запускает подтверждение изменений в GitHub. В кейсе разберу, как связаны сборка, установка и проверка новой версии и как воспроизвести этот процесс для своего сайта.

Новая версия собирается, пока работает предыдущая

Главное разделение в этой схеме — подготовка версии и её запуск. После слияния PR сервер получает конкретный коммит и собирает его в отдельном каталоге. Работающий сайт продолжает использовать прежний релиз. Ошибка установки зависимостей или сборки останавливает обновление до переключения.

Готовая сборка тоже получает свой каталог. Ссылка current указывает на версию, из которой запускается сайт. Перед перезапуском её переводят на новый релиз; предыдущий каталог остаётся на месте для возможного возврата.

На примере каталогов это выглядит так:

/srv/content-site/
  releases/
    release-previous/
      server/index.mjs
      public/
    release-new/
      server/index.mjs
      public/
  current -> releases/release-previous

В начале current указывает на release-previous. После подготовки release-new меняется ссылка и перезапускается процесс. Установщик проверяет запуск, чтобы решить, оставлять ли новую версию. Ниже покажу весь сценарий, включая возврат предыдущей версии при неудачной локальной проверке.

Примеры рассчитаны на уже подготовленный Linux-сервер с первым рабочим релизом. Настройки systemd, Nginx и прав пользователя приведены в разделе «Что подготовить на сервере». Для запуска сценария их нужно выполнить заранее.

Передать серверу именно тот коммит, который приняли

Для alexeslm.ru отправной точкой служит слияние PR в master. Workflow получает событие pull_request с типом closed, затем проверяет merged == true. Простое закрытие PR и обычный push в ветку выкладку не запускают.

Вместе с командой сервер получает merge_commit_sha — идентификатор принятого коммита. Так запуск связан с конкретной версией кода. Если за время ожидания появятся новые изменения, сценарий сможет обнаружить, что его версия устарела.

В .github/workflows/deploy.yml это можно записать так. Вариант рассчитан на PR из веток того же репозитория:

name: Deploy site
on:
  pull_request:
    branches: [master]
    types: [closed]
permissions:
  contents: read
concurrency:
  group: production
  cancel-in-progress: false
jobs:
  deploy:
    if: github.event.pull_request.merged == true
    runs-on: ubuntu-latest
    timeout-minutes: 45
    environment: production
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
        with:
          ref: ${{ github.event.pull_request.merge_commit_sha }}
          persist-credentials: false
      - name: Run deployment on server
        shell: bash
        env:
          DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
          DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
          DEPLOY_REPO: ${{ secrets.DEPLOY_REPO }}
          DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
          DEPLOY_HOST_KEYS: ${{ secrets.DEPLOY_HOST_KEYS }}
          DEPLOY_SHA: ${{ github.event.pull_request.merge_commit_sha }}
        run: |
          set -euo pipefail
          : "${DEPLOY_HOST:?}" "${DEPLOY_USER:?}" "${DEPLOY_REPO:?}"
          : "${DEPLOY_KEY:?}" "${DEPLOY_HOST_KEYS:?}"
          ssh_dir=$(mktemp -d "$RUNNER_TEMP/site-ssh.XXXXXX")
          trap 'rm -rf -- "$ssh_dir"' EXIT
          printf '%s\n' "$DEPLOY_KEY" > "$ssh_dir/key"
          printf '%s\n' "$DEPLOY_HOST_KEYS" > "$ssh_dir/known_hosts"
          chmod 600 "$ssh_dir/"*
          printf -v command 'bash -s -- %q %q' "$DEPLOY_REPO" "$DEPLOY_SHA"
          status=0
          ssh -i "$ssh_dir/key" -o BatchMode=yes -o IdentitiesOnly=yes \
            -o StrictHostKeyChecking=yes \
            -o UserKnownHostsFile="$ssh_dir/known_hosts" \
            "$DEPLOY_USER@$DEPLOY_HOST" "$command" < deploy/deploy.sh || status=$?
          if (( status == 21 )); then
            echo 'Skipped: a newer commit is already in master.'
          else
            exit "$status"
          fi

Workflow берёт deploy/deploy.sh из принятого коммита и передаёт его по SSH через stdin. Абсолютный путь к репозиторию на сервере и SHA идут аргументами с экранированием для Bash. Серверный скрипт приведён ниже, после шагов сборки и проверки.

Для подключения в GitHub создаётся environment production с пятью secrets: DEPLOY_HOST — SSH-хост, DEPLOY_USER — пользователь выкладки, DEPLOY_REPO — путь к репозиторию, DEPLOY_KEY — приватный ключ входа, DEPLOY_HOST_KEYS — проверенная запись known_hosts сервера. Если у environment включено обязательное одобрение, запуск дождётся его.

Группа concurrency не даёт двум production-запускам выполняться одновременно. cancel-in-progress: false сохраняет уже работающий запуск; ожидающий GitHub может заменить более новым. Это существенно для переключения релизов: начатый процесс должен закончить свой сценарий.

Собрать релиз вне работающего приложения

На сервере исходники сначала обновляются через git fetch. Затем git worktree создаёт отдельную рабочую копию нужного SHA. Основной checkout сохраняет своё состояние, а сборка получает собственный каталог. В примере незакоммиченные изменения в основном checkout останавливают обновление.

Внутри worktree выполняется npm ci --include=dev: зависимости устанавливаются по package-lock.json, включая инструменты сборки TypeScript и CSS. Работающий Node-процесс использует готовый релиз, поэтому эта установка не меняет его файлы.

На alexeslm.ru команда npm run release проверяет контент, собирает приложение, выполняет typecheck и упаковывает .output. Для примера объединим эти действия в deploy/release.sh:

#!/usr/bin/env bash
set -euo pipefail
export SITE_INCLUDE_REVIEW=false
export SITE_INDEXABLE=true
export SITE_URL=https://example.com
npm run test:content
npm run build
npm run typecheck
mkdir -p dist
tar -czf dist/site.tar.gz -C .output .
(cd dist && sha256sum site.tar.gz > site.tar.gz.sha256)

test:content и typecheck здесь — команды проекта, а build запускает Nuxt и его prebuild с компиляцией материалов. Для своего проекта определите соответствующие проверки в package.json. SITE_URL замените каноническим доменом: часть настроек предварительно подготовленных страниц формируется во время сборки.

SITE_INCLUDE_REVIEW=false исключает редакционные материалы из публичной сборки. Архив получает содержимое .output: серверный код, его зависимости и публичные ресурсы. Рядом сохраняется SHA256, по которому установщик проверит целостность архива перед распаковкой.

На этом этапе есть файл релиза. Работающий сайт всё ещё использует прежнюю версию.

За время сборки мог появиться следующий PR

Допустим, один PR уже запустил сборку, а в master успели слить следующий. Если установить первую версию без проверки, сайт получит коммит, который уже перестал быть последним.

Сценарий alexeslm.ru сравнивает переданный SHA с вершиной ветки дважды: перед сборкой и перед установкой. При несовпадении пропускает выкладку. В примере это код завершения 21; workflow обрабатывает его как пропуск, остальные ошибки делают job неуспешной.

Группа concurrency действует внутри GitHub. На сервере весь цикл дополнительно закрывает flock, поскольку тот же сценарий можно вызвать вручную. Установщик проекта также имеет собственную блокировку этапа установки.

Между последней проверкой SHA и переключением всё равно может прийти новый merge. В таком случае последующий запуск подготовит следующую версию. Две проверки позволяют отсеять устаревший запуск до установки, когда новый коммит уже известен серверу.

Переключить версию и проверить запуск

Перед переключением сценарий запоминает, куда указывает current. Архив распаковывается в новый каталог внутри releases, затем рядом создаётся новая ссылка. Команда mv -Tf заменяет ею current, после чего systemd перезапускает Node-процесс.

Для локальной проверки в примере используется Nuxt-обработчик server/routes/healthz.get.ts:

export default defineEventHandler(() => ({ status: 'ok' }))

После перезапуска скрипт ждёт активную службу и успешный ответ /healthz. Если процесс не запустился или endpoint недоступен, он возвращает ссылку на предыдущий каталог, снова перезапускает службу и повторяет проверку. Даже удачный возврат завершает деплой с ошибкой: новая версия не установлена.

Теперь соберём подготовку и установку в deploy/deploy.sh. Файл использует release.sh из предыдущего раздела, существующий current и права из раздела настройки сервера:

#!/usr/bin/env bash
set -euo pipefail
repo=$(realpath "${1:?Repository path required}")
sha=${2:?Commit SHA required}
[[ "$sha" =~ ^[0-9a-f]{40}$ ]]
[[ $(id -u) != 0 ]]
root=${DEPLOY_ROOT:-/srv/content-site}
service=content-site.service
health_url=http://127.0.0.1:3100/healthz
export GIT_TERMINAL_PROMPT=0
export GIT_SSH_COMMAND='ssh -o BatchMode=yes -o StrictHostKeyChecking=yes'
cd "$repo"
[[ $(git rev-parse --show-toplevel) == "$repo" ]]
exec 9>"$(git rev-parse --git-common-dir)/deploy.lock"
flock -n 9 || { echo 'Deployment already running' >&2; exit 1; }
[[ -z $(git status --porcelain) ]] || { echo 'Repository has local changes' >&2; exit 20; }
sudo -n -l /usr/bin/systemctl restart "$service" >/dev/null

check_commit() {
  git fetch --no-tags origin refs/heads/master:refs/remotes/origin/master
  [[ $(git rev-parse origin/master) == "$sha" ]] || return 21
}
check_commit
build=$(mktemp -d)
cleanup() {
  git -C "$repo" worktree remove --force "$build/source" 2>/dev/null || true
  rm -rf -- "$build"
}
trap cleanup EXIT
git worktree add --detach "$build/source" "$sha"
cd "$build/source"
npm ci --include=dev
bash deploy/release.sh
(cd dist && sha256sum --check site.tar.gz.sha256)
check_commit

previous=$(readlink -f "$root/current")
[[ -d "$previous" ]] # Первый рабочий релиз устанавливается заранее.
release=$(mktemp -d "$root/releases/${sha:0:12}-XXXXXX")
tar -xzf dist/site.tar.gz -C "$release" --no-same-owner
test -f "$release/server/index.mjs"

switch_release() {
  local link
  link="$root/current.$RANDOM.$RANDOM"
  ln -sT "$1" "$link"
  mv -Tf "$link" "$root/current"
}
healthy() {
  for attempt in {1..15}; do
    if systemctl is-active --quiet "$service" \
      && curl --fail --silent --max-time 2 "$health_url" >/dev/null; then
      return 0
    fi
    sleep 1
  done
  return 1
}

switch_release "$release"
if sudo -n /usr/bin/systemctl restart "$service" && healthy; then
  printf 'Deployed %s\n' "$sha"
else
  echo 'New release failed; restoring previous release' >&2
  switch_release "$previous"
  sudo -n /usr/bin/systemctl restart "$service"
  healthy || echo 'Rollback also needs attention' >&2
  exit 1
fi

curl --fail --show-error --max-time 20 https://example.com/healthz

В конце выполняется отдельный запрос к публичному HTTPS-адресу; здесь тоже нужно заменить example.com своим доменом. Если внешний запрос завершился ошибкой, этот пример оставляет выбранный релиз и возвращает ненулевой код. Причину нужно определить отдельно: на доступность домена влияют прокси, DNS и сертификат. Точные условия автоматического отката зависят от инфраструктуры.

Локальный ответ {"status":"ok"} показывает, что серверный обработчик доступен. Содержимое статей, CSS и клиентские переходы проверяются отдельно при приёмке сайта.

Перезапуск единственного Node-процесса допускает короткий перерыв в работе сайта. Для обновления без перерыва понадобятся два одновременно работающих экземпляра и переключение трафика между ними.

После завершения временный worktree удаляется, установленные релизы остаются. Для очистки старых каталогов нужна отдельная политика, сохраняющая проверенную версию для отката.

Что подготовить на сервере

В этой схеме сборка выполняется обычным пользователем. Повышенные права нужны для перезапуска конкретной службы. В примерах используются пользователь publisher, служба content-site.service и каталог /srv/content-site.

Потребуются Git, Bash, npm, curl, tar, sha256sum и flock. Совместимый Node должен быть доступен в PATH неинтерактивной SSH-сессии; путь /opt/node/bin/node в unit указывает на тот же выбранный runtime. Пользователю выкладки нужны права создавать каталоги в releases и переключать current. Первый рабочий релиз и HTTPS на домене устанавливаются до включения автодеплоя.

Systemd запускает приложение через current. Файл /etc/systemd/system/content-site.service:

[Unit]
Description=Nuxt content site
After=network.target

[Service]
User=publisher
Group=publisher
WorkingDirectory=/srv/content-site/current
ExecStart=/opt/node/bin/node /srv/content-site/current/server/index.mjs
Environment=HOST=127.0.0.1
Environment=PORT=3100
Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.target

Администратор разрешает пользователю перезапуск этой службы в отдельном файле sudoers:

publisher ALL=(root) NOPASSWD: /usr/bin/systemctl restart content-site.service

Правило проверяется командой visudo -c, доступ пользователя — sudo -n -l /usr/bin/systemctl restart content-site.service. После установки unit выполняются systemctl daemon-reload и systemctl enable --now content-site.service.

Nginx принимает внешний трафик и передаёт запросы приложению на loopback. В HTTPS-блок домена с уже настроенным сертификатом добавляется:

location / {
    proxy_pass http://127.0.0.1:3100;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}

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

У SSH здесь два соединения: Actions подключается к серверу, а сервер читает репозиторий в GitHub. DEPLOY_HOST_KEYS относится к первому соединению. Для второго серверу отдельно нужны доступ к репозиторию и доверенный ключ GitHub в его known_hosts. Ключи серверов проверяются заранее.

В уведомлении виден результат конкретного запуска

Workflow alexeslm.ru отправляет в Telegram итог: успех, блокировку из-за локальных изменений, пропуск устаревшего коммита или ошибку. В сообщении есть SHA, PR и ссылка на журнал Actions. По ним можно найти изменение и шаг, на котором остановилось обновление. Ошибка отправки сообщения не отменяет установленный релиз.

Так слияние PR запускает подготовку конкретной версии, а установка получает уже собранный архив. До переключения сайт использует прежний релиз; после него сценарий проверяет запуск и при предусмотренной неудаче возвращает предыдущую версию. Разработчик получает результат операции и журнал для разбора ошибок.

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