Деплой без боли: как выкатить WordPress на сервер и откатиться за секунду

В CI/CD своими словами мы разобрали теорию: чем сборка отличается от доставки и почему валидные ключи YAML нельзя путать между системами. Эта статья — про вторую половину слова «деплой»: как технически выложить новый релиз на живой сервер, чтобы пользователи не увидели полусломанный сайт, и как откатиться за секунду, если что-то пошло не так. Покажем приём, который работает и руками по SSH, и внутри пайплайна: символьная ссылка current, health-check перед переключением и переиспользуемые releases. Всё на условном домене example.test; домены и пути в примерах подставьте свои.

Часть I. Почему «rsync поверх живого» — это боль

Самый частый «деплой» на самописном VPS: открыть терминал и прогнать rsync/scp новой версии в /var/www/example.test, поверх того, что прямо сейчас обслуживает посетителей. Плохо здесь вот что:

  • Нет атомарности. Копирование занимает секунды–минуты. В этот момент один посетитель тянет уже новый index.php, а другой — ещё старый шаблон. PHP склеивает их и ловит фатал: функция из нового файла вызывается кодом из старого, файла ещё нет — белый экран.
  • Нет точки «всё или ничего». Если перенос оборвался на середине (обвалилась сеть, кончилась диск-квота), сервер остаётся в гибридном состоянии, и вы чините его руками под давлением «сайт лежит».
  • Нет отката. Чтобы вернуться, нужно снова копировать — теперь уже старую версию, тоже поверх живого, с той же гонкой. Откат = второй выкат = вторая боль.
  • Нет проверки перед тем, как показать пользователям. Новая версия попадает в бой «как есть», и первый, кто увидит поломку, — реальный посетитель.

Хорошая новость: всё это лечится одним приёмом, который не требует ни второй машины, ни дорогой системы доставки.

Чек-лист части

  • Выкат идёт НЕ копированием поверх обслуживаемого каталога.
  • Есть способ вернуть прежнюю версию быстрее, чем «перекачать заново».
  • Пользователь в любую секунду видит либо целиком старый, либо целиком новый сайт.

Часть II. Структура releases + симлинк current

Классическая раскладка (её популяризировал Capistrano, но принцип старше и независим от инструментов):

# bash —layout на сервере
/var/www/example.test/
├── current            # символьная ссылка на активный релиз (сюда смотрит nginx)
├── shared/            # то, что НЕ принадлежит конкретному релизу
│   ├── uploads/       #   wp-content/uploads
│   ├── wp-config.php  #   конфиг с ключами и доступом к БД
│   └── .env
└── releases/
    ├── 20261020-1145/ #   прошлый релиз (держим для отката)
    └── 20261022-1015/ #   новый релиз, лежит, но пока НЕ активен

current — это симлинк на одну из папок в releases/. Нginx в конфиге указывает не на /var/www/example.test, а на /var/www/example.test/current. Значит, «переключить сайт на новую версию» = «перенаправить current на другую папку». Физически файлы никуда не копируются в момент переключения.

Bash
# bash — один раз перенастроить nginx на симлинк
# в server-блоке:
root /var/www/example.test/current;

После правки конфига проверьте синтаксис и перечитайте nginx без разрыва соединений:

Bash
# bash
sudo nginx -t && sudo systemctl reload nginx

reload (не restart) перезапускает воркеры плавно, активные соединения не обрываются.

Как рождается релиз

Bash
# bash — раскладка нового релиза (метка = дата-время)
set -euo pipefail
REL="/var/www/example.test/releases/$(date +%Y%m%d-%H%M)"

# вариант 1: распаковать собранный артефакт (tarball из пайплайна)
mkdir -p "$REL"
tar -xzf /tmp/app.tar.gz -C "$REL"

# вариант 2: клон из git на нужную метку/тег
# git clone --depth 1 --branch v1.8.3 https://git.example/site.git "$REL"

Релиз готов и лежит рядом, но сайт всё ещё на старом current — потому что переключать будем только после проверки.

Чек-лист части

  • nginx/FPM смотрят на `current`, а не на фиксированный путь релиза.
  • Каждый выкат — отдельная новая папка в `releases/`, а не перезапись существующей.
  • Прошлые релизы сохраняются (это наш будущий откат).

Часть III. Health-check перед переключением

Переключать current на новый релиз можно только после того, как убедились: новый код рабочий. Проверяют это, временно «поднимая» новый релиз на проверочном пути или хосте и дёрнув его локально, не трогая боевой симлинк.

Самый дешёвый способ без настройки второго vhost — проверить, что релиз отдаёт страницу, через PHP built-in сервер или через запрос к временному location. Ниже — переносимая проверка через curl на локально поднятом сервере новой версии:

# bash — поднять новый релиз на временном порту и проверить
set -euo pipefail
REL_LATEST="$(ls -1 /var/www/example.test/releases | tail -1)"
PORT=8099

php -S "127.0.0.1:${PORT}" -t "/var/www/example.test/releases/${REL_LATEST}" >/tmp/rel_srv.log 2>&1 &
SRV_PID=$!
sleep 2

HTTP_CODE="$(curl -s -o /tmp/rel_home.html -w '%{http_code}' "http://127.0.0.1:${PORT}/")"
kill "$SRV_PID"

if [ "$HTTP_CODE" != "200" ]; then
  echo "Релиз не отдаёт главную (код $HTTP_CODE) — НЕ переключаем." >&2
  exit 1
fi
# плюс проверка, что не вылез фатал прямо в тело
if grep -qiE "Fatal error|Parse error|Uncaught|Warning: " /tmp/rel_home.html; then
  echo "В ответе видна PHP-ошибка — НЕ переключаем." >&2
  exit 1
fi
echo "Health-check пройден, можно переключать."

Примечание: php -S — однопоточный дев-сервер, он годится ровно для «отдаёт/не отдаёт» на localhost, а не для нагрузки. В серьёзном пайплайне health-check бьёт по реальному боевому пути на staging (см. Обновления без простоя, Часть IV — staging на одной машине), а не по дев-серверу. Логика та же: код 200 + нет фатала + нужный контент в теле.

Только после зелёной проверки переключаем симлинк — атомарно:

# bash — переключение current (atomic swap)
set -euo pipefail
BASE="/var/www/example.test"
REL_LATEST="$(ls -1 "$BASE/releases" | tail -1)"
ln -sfn "$BASE/releases/${REL_LATEST}" "$BASE/current.new"  # собрать новый симлинк
mv -Tf "$BASE/current.new" "$BASE/current"                  # заменить одним move() — атомарно

Почему именно так: ln -sfn создаёт временный симлинк current.new, а mv -Tf переименовывает его в current. Для ядра это одна операция rename() — посетитель не может поймать «между» состоянием: либо старый current, либо новый, без дыры. Прямой ln -sfn по уже существующему current в некоторых версиях ядра на долю секунды может «снимать» ссылку — потому и обход через временный файл.

Чек-лист части

  • Переключение `current` происходит **только после** успешного health-check нового релиза.
  • Замена симлинка — атомарная (`mv -T`), без окна «сайта нет».
  • Проверка смотрит не только на код 200, но и на отсутствие фатала в ответе.

Часть IV. WordPress: загрузки и конфиг живут вне релиза

У WordPress есть вещи, которые нельзя хранить внутри папки релиза, иначе при каждом выкате вы их теряете или перетираете:

  • wp-content/uploads — загруженные пользователями картинки/файлы. Их нет в git и в артефакте сборки.
  • wp-config.php (или .env) — пароли к базе, ключи AUTH_SALT, доступ к API. Коммитить их в репозиторий нельзя, а переписывать при выкате — риск.
  • Кэш и временные папки (wp-content/cache, wp-content/upgrade).

Решение — держать их в shared/ (см. Часть II) и прилинковывать внутрь каждого релиза на этапе подготовки, до health-check:

# bash — прилинковать общие куски в свежий релиз
set -euo pipefail
BASE="/var/www/example.test"
REL_LATEST="$(ls -1 "$BASE/releases" | tail -1)"
REL_DIR="$BASE/releases/${REL_LATEST}"

# 1) папка загрузок: общая, живёт между релизами
mkdir -p "$BASE/shared/uploads" "$REL_DIR/wp-content"
ln -sfn "$BASE/shared/uploads" "$REL_DIR/wp-content/uploads"

# 2) конфиг: один общий файл, симлинк внутрь релиза
[ -f "$BASE/shared/wp-config.php" ] && ln -sfn "$BASE/shared/wp-config.php" "$REL_DIR/wp-config.php"

# 3) прочие общие папки — по той же схеме (кэш, логи)
mkdir -p "$BASE/shared/cache"
ln -sfn "$BASE/shared/cache" "$REL_DIR/wp-content/cache"

Ключевое свойство симлинков: они указывают на общую папку, а не копируют её. Загрузили картинку сегодня — она лежит в shared/uploads и останется на месте, какой бы релиз ни висел на current. Это же спасает откатами: откат кода не трогает загрузки и базу.

Проверьте права — PHP-FPM работает от своего пользователя, и current/релиз/shared должны читаться им:

# bash — владелец под FPM (uUser обычно site или www-data)
FPM_USER=site_example
chown -R "$FPM_USER":"$FPM_USER" "$BASE/shared" "$BASE/releases"
find "$BASE/releases" -type d -exec chmod 755 {} \;
find "$BASE/releases" -type f -exec chmod 644 {} \;

Чек-лист части

  • `uploads`, `wp-config`/`.env`, кэш — в `shared/`, прилинкованы в релиз, не лежат внутри папки релиза.
  • Откат кода не теряет загрузки и доступ к БД (общие файлы вне релизов).
  • Права позволяют пользователю FPM читать `current` и все симлинки в нём.

Часть V. Откат за секунду

Раз current — просто симлинк, а прошлые релизы целы, откат = переключение симлинка назад. Никакого «перекачать старую версию».

# bash — откат: current на предыдущий релиз
set -euo pipefail
BASE="/var/www/example.test"
# предпоследний релиз = тот, что был активен до последнего
PREV="$(ls -1 "$BASE/releases" | tail -2 | head -1)"
if [ -z "$PREV" ]; then echo "Нет предыдущего релиза для отката." >&2; exit 1; fi
ln -sfn "$BASE/releases/${PREV}" "$BASE/current.new"
mv -Tf "$BASE/current.new" "$BASE/current"
echo "Откатнули current на ${PREV}. Проверьте сайт."
curl -s -o /dev/null -w 'home: %{http_code}\n' "http://example.test/"

Перекидка занимает миллисекунды — это одна rename() в ядре. Пользователи увидят прежний сайт, даже не заметив аварии. Именно поэтому релизы не удаляем сразу: держим, скажем, 5 последних, а чистим отдельно (и не в момент аварии).

Важный нюанс: откат кода безопасен только если схема БД не уехала вперёд. Если новый релиз успел снести миграцию базы — возврат current вернёт код, который не понимает новую схему. Как совместить миграции с откатом — в Части VI.

Чек-лист части

  • Откат — одна команда переключения симлинка, без переустановки файлов.
  • Держим минимум несколько прошлых релизов (история для отката).
  • Откат кода протестирован заранее (не в момент, когда сайт лежит).

Часть VI. Миграции базы: шаг до переключения, а не после

Код переключается атомарно, а база «переключается» нет: ALTER TABLE нельзя откатить симлинком. Правило, которое спасает связку «откатился — сайт живой»:

  • Схема должна быть обратно совместима (expand/contract). Новый код работает и на старой схеме, и на новой. Сначала добавили столбец → выкатили код, который его читает; удалить старый столбец можно позже, отдельным шагом. Тогда возврат кода безопасен.
  • Миграцию запускают ДО переключения current, на новом релизе, на staging — и только зелёную гонят на боевую БД, затем переключают код.
  • Миграция идемпотентна: повторный запуск не должен ломать (есть ли такой столбец? нет — создаём, есть — пропускаем).

Схема выката с миграцией:

# bash — порядок: миграция (на копии) → проверка → миграция (боевая) → переключение кода
set -euo pipefail
wp db check                       # база доступна?
wp plugin deactivate maintenance  # если нужен режим — включаем на минуту
# запуск миграций плагина/кода (имена команд зависят от проекта), на БОЕВОЙ базе:
wp eval "My\Updater::migrate();" || { echo "Миграция упала — НЕ переключаем."; exit 1; }
# только теперь переключаем current (из Части III)

Для WordPress типичные миграции — это dbDelta в плагинах, обновление структуры таблиц при апгрейде ядра, и «мягкие» изменения, которые сам WP умеет откатывать частично. Грубые DROP COLUMN — только после того, как новый код, не использующий этот столбец, уже «прожил» на проде и откат не потребуется.

Чек-лист части

  • Миграция обратно совместима: возврат кода не требует возврата схемы.
  • Миграция идемпотентна (повтор не ломает).
  • Боевая миграция идёт до переключения `current`, и только после зелёной проверки.

Часть VII. Готовый deploy.yml для GitHub Actions

Складываем всё выше в один пайплайн. Он выкладывает на тот же releases + current, что и ручные команды из Частей II–V, и делает ровно то же самое: собирает релиз в новую папку → прилинковывает shared → гоняет миграцию → проверяет health → атомарно переключает симлинк → при неудаче сам откатывает current назад. Переключается только на main (или вручную), ключи — только через secrets, никогда литералом.

YAML
# .github/workflows/deploy.yml
name: Deploy WordPress (atomic releases)

on:
  push:
    branches: [ main ]      # прод только с main; фича-ветки не деплоятся
  workflow_dispatch: {}      # ручной выкат той же стадией

# одна деплой-гонка одновременно: второй выкат не перетирает первый
concurrency:
  group: deploy-example-test
  cancel-in-progress: false

jobs:
  deploy:
    runs-on: ubuntu-latest
    # все шаги выполняются на боевом сервере через SSH-агент к одному хосту
    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Настроить SSH-ключ
        uses: webfactory/ssh-agent@v0.9.0
        with:
          ssh-private-key: ${{ secrets.DEPLOY_SSH_KEY }}   # приватный ключ, НЕ публичный, НЕ пароль

      - name: Проверить связность
        run: |
          ssh -o StrictHostKeyChecking=accept-new "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" "echo ok"

      # 1) Собираем релиз в НОВУЮ папку releases/<метка>. Код ещё НЕ активен.
      - name: Развернуть релиз (в releases/, не трогая current)
        run: |
          ssh "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" "bash -s" <<'REMOTE'
          set -euo pipefail
          BASE=/var/www/example.test
          REL="$BASE/releases/$(date +%Y%m%d-%H%M%S)"
          mkdir -p "$REL"
          echo "новый релиз: $REL"
          REMOTE
          # содержимое репозитория переносим через ssh+tar (без гит-ключей на сервере):
          # самый свежий каталог в releases/ — только что созданный, в него и распаковываем
          tar -czf - --exclude=.git . | \
            ssh "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" \
            "REL=\$(ls -1dt /var/www/example.test/releases/*/ | head -1); tar -xzf - -C \"\$REL\""

      # 2) Прилинковать shared-файлы WordPress в свежий релиз (uploads, wp-config, кэш).
      - name: Примапить shared
        run: |
          ssh "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" "bash -s" <<'REMOTE'
          set -euo pipefail
          BASE=/var/www/example.test
          REL=$(ls -1dt "$BASE"/releases/*/ | head -1)
          ln -sfn "$BASE/shared/uploads"     "$REL/wp-content/uploads"
          ln -sfn "$BASE/shared/wp-config.php" "$REL/wp-config.php"
          ln -sfn "$BASE/shared/cache"        "$REL/wp-content/cache"
          chown -R site_example:site_example "$REL"
          REMOTE

      # 3) Миграция БД (идемпотентная, обратно-совместимая) ДО переключения кода.
      - name: Миграция базы
        run: |
          ssh "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" "bash -s" <<'REMOTE'
          set -euo pipefail
          BASE=/var/www/example.test
          REL=$(ls -1dt "$BASE"/releases/*/ | head -1)
          cd "$REL"
          # если миграция упала — выходим nonzero, следующий шаг свича не выполнится
          wp eval "My\\Updater::migrate();" --allow-root || { echo 'Миграция упала'; exit 1; }
          REMOTE

      # 4) Health-check нового релиза (php -S на localhost) — НЕ переключаем, если не 200/нет фатала.
      - name: Health-check нового релиза
        run: |
          ssh "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" "bash -s" <<'REMOTE'
          set -euo pipefail
          BASE=/var/www/example.test
          REL=$(ls -1dt "$BASE"/releases/*/ | head -1)
          php -S 127.0.0.1:8099 -t "$REL" >/tmp/rel_srv.log 2>&1 &
          SRV=$!; sleep 2
          CODE=$(curl -s -o /tmp/rel_home.html -w '%{http_code}' http://127.0.0.1:8099/)
          kill "$SRV"
          [ "$CODE" = "200" ] || { echo "health: код $CODE"; exit 1; }
          grep -qiE 'Fatal error|Parse error|Uncaught' /tmp/rel_home.html && { echo 'в теле фатал'; exit 1; }
          echo 'health ok'
          REMOTE

      # 5) Атомарное переключение current на проверенный релиз (mv -T = одна rename()).
      - name: Атомарный свитч current
        run: |
          ssh "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" "bash -s" <<'REMOTE'
          set -euo pipefail
          BASE=/var/www/example.test
          REL=$(ls -1dt "$BASE"/releases/*/ | head -1)
          ln -sfn "$REL" "$BASE/current.new"
          mv -Tf "$BASE/current.new" "$BASE/current"
          REMOTE

      # 6) Финальная проверка боевой главной. Если упала — АВТО-ОТКАТ на предпоследний релиз.
      - name: Продакшн-проверка и авто-откат
        if: always()
        run: |
          ssh "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" "bash -s" <<'REMOTE'
          set -uo pipefail
          BASE=/var/www/example.test
          CODE=$(curl -s -o /dev/null -w '%{http_code}' -k https://example.test/)
          PREV=$(ls -1dt "$BASE"/releases/*/ | sed -n '2p')
          if [ "$CODE" != "200" ] && [ -n "$PREV" ]; then
            ln -sfn "$PREV" "$BASE/current.new"
            mv -Tf "$BASE/current.new" "$BASE/current"
            echo "Продакшн код $CODE — ОТКАТУЮ на $(basename "$PREV")"; exit 1
          fi
          echo "прод ok (код $CODE)"
          REMOTE

      - name: Почистить старые релизы (оставить 5)
        run: |
          ssh "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" \
            "cd /var/www/example.test/releases && ls -1dt */ | tail -n +6 | xargs -r rm -rf"

Что здесь важно сверять со своей инфраструктурой:

  • secrets.* — не литералы. DEPLOY_SSH_KEY — приватный ключ доступа (только на GitHub, не в репо); ключевые строки БД и AUTH_SALT живут в shared/wp-config.php на сервере, пайплайн их вообще не видит. Закоммиченный в YAML ключ — уже скомпрометированный (см. CI/CD своими словами).
  • Порядок шагов = логика выката. Развернул → shared → миграция → health → свитч → прод-проверка/откат. Свич стоит строго после зелёного health, авто-откат — под if: always(), чтобы сработать и когда предыдущий шаг упал.
  • concurrency не даёт двум выкатам перетирать current одновременно.
  • tar | ssh кладёт код без git-ключей на сервер; если у вас клон из приватного репо — замените на git clone с secrets.GITHUB_TOKEN.
  • Пути, site_example, My\Updater::migrate() — подставьте свои; это тот же скелет, что в ручных командах выше.

Тот же каркас для второй системы — в Части VIII (.gitlab-ci.yml). А без ручной вёрстки YAML любой из них соберёт генератор ниже по-человечески описанному проекту.

Часть VIII. Тот же выкат для GitLab CI: .gitlab-ci.yml

Это не «копипаст с правками», а осознанная демонстрация тезиса из CI/CD своими словами: у каждой системы свой закрытый синтаксис. Тот же атомарный выкат — развернул → shared → миграция → health-check → свитч → авто-откат — на GitLab пишется другими ключами: stages вместо jobs, rules вместо on.push.branches, переменные CI/CD вместо secrets.*, resource_group вместо concurrency, а ssh-ключ кладут на диск руками, потому что готового ssh-агент-экшена как в GitHub тут нет.

YAML
# .gitlab-ci.yml
stages: [deploy]

# деплоим только push в main (плюс ручной запуск из UI) — аналог on.push.branches: [main]
workflow:
  rules:
    - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "main"'
    - if: '$CI_PIPELINE_SOURCE == "web"'          # ручной запуск кнопкой

variables:
  BASE: "/var/www/example.test"
  WP_USER: "site_example"

.deploy_prep: &deploy_prep
  # общий пред-шаг: поднять ssh-агент и положить приватный ключ из переменной CI/CD.
  # в GitLab ключ — это masked file-переменная DEPLOY_SSH_KEY (Settings → CI/CD → Variables),
  # НЕ строка в этом файле. Закоммитить ключ = скомпрометировать ключ.
  image: alpine:3.20
  before_script:
    - apk add --no-cache openssh-client bash tar curl
    - eval "$(ssh-agent -s)"
    - mkdir -p ~/.ssh
    - printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/id_deploy   # ключ из file-переменной
    - chmod 600 ~/.ssh/id_deploy
    - ssh-add ~/.ssh/id_deploy
    - ssh-keyscan -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts 2>/dev/null
  # сериализация: параллельные пайплайны не дерутся за один current (аналог concurrency)
  resource_group: deploy-example-test
  environment:
    name: production

deploy:
  stage: deploy
  <<: *deploy_prep
  script:
    # 1) свежая папка релиза + перенос кода tar | ssh (без гит-ключей на сервере)
    - |
      REL="$BASE/releases/$(date +%Y%m%d-%H%M%S)"
      ssh "$DEPLOY_USER@$DEPLOY_HOST" "set -euo pipefail; mkdir -p '$REL'"
      tar -czf - --exclude=.git . \
        | ssh "$DEPLOY_USER@$DEPLOY_HOST" "tar -xzf - -C '$REL'"
      # 2) shared-файлы WordPress (uploads, wp-config, кэш) + права FPM
      ssh "$DEPLOY_USER@$DEPLOY_HOST" "set -euo pipefail
        ln -sfn '$BASE/shared/uploads'      '$REL/wp-content/uploads'
        ln -sfn '$BASE/shared/wp-config.php' '$REL/wp-config.php'
        ln -sfn '$BASE/shared/cache'         '$REL/wp-content/cache'
        chown -R '$WP_USER':'$WP_USER' '$REL'"
      # 3) миграция БД ДО переключения (идемпотентная, обратно-совместимая)
      ssh "$DEPLOY_USER@$DEPLOY_HOST" "cd '$REL' && wp eval 'My\\Updater::migrate();' --allow-root"
      # 4) health-check нового релиза: код 200 и отсутствие фатала — иначе падаем ДО свитча
      ssh "$DEPLOY_USER@$DEPLOY_HOST" "set -euo pipefail
        php -S 127.0.0.1:8099 -t '$REL' >/tmp/rel_srv.log 2>&1 &
        SRV=\$!; sleep 2
        CODE=\$(curl -s -o /tmp/rel_home.html -w '%{http_code}' http://127.0.0.1:8099/)
        kill \$SRV
        [ \"\$CODE\" = 200 ] || { echo health:\$CODE; exit 1; }
        grep -qiE 'Fatal error|Parse error|Uncaught' /tmp/rel_home.html && { echo 'fatal in body'; exit 1; }"
      # 5) атомарный свитч current (одна rename через mv -T) — запоминаем PREV для отката
      ssh "$DEPLOY_USER@$DEPLOY_HOST" "set -euo pipefail
        PREV=\$(readlink -f '$BASE/current' || true)
        echo \"\$PREV\" > '$BASE/.rollback_target'
        ln -sfn '$REL' '$BASE/current.new'; mv -Tf '$BASE/current.new' '$BASE/current'"

# 6) авто-откат: отдельная job с when:always — сработает и если deploy упал/после свича
rollback:
  stage: deploy
  <<: *deploy_prep
  when: always            # аналог if: always() из GitHub Actions
  needs: [deploy]
  script:
    - |
      CODE=$(ssh "$DEPLOY_USER@$DEPLOY_HOST" "curl -s -o /dev/null -w '%{http_code}' -k https://example.test/")
      PREV=$(ssh "$DEPLOY_USER@$DEPLOY_HOST" "cat '$BASE/.rollback_target' 2>/dev/null || true")
      if [ "$CODE" != "200" ] && [ -n "$PREV" ]; then
        ssh "$DEPLOY_USER@$DEPLOY_HOST" "ln -sfn '$PREV' '$BASE/current.new'; mv -Tf '$BASE/current.new' '$BASE/current'"
        echo "прод=$CODE — откачен на $PREV"
        exit 1
      fi
      echo "прод ok (код $CODE)"

# 7) чистка старых релизов (держать 5) — только при успешном выкате
cleanup:
  stage: deploy
  <<: *deploy_prep
  needs: [rollback]
  when: on_success
  script:
    - ssh "$DEPLOY_USER@$DEPLOY_HOST" "cd '$BASE/releases' && ls -1dt */ | tail -n +6 | xargs -r rm -rf"

Чем GitLab-версия отличается от GitHub-овской по ключам (это и есть «система ≠ пайплайн»):

ЧтоGitHub ActionsGitLab CI
Триггер «только main»on.push.branches: [main]workflow.rules + $CI_COMMIT_BRANCH
Секреты${{ secrets.X }}$X из Settings → CI/CD → Variables
SSH-ключwebfactory/ssh-agent (готовый экшен)кладём руками: printf → ssh-add
Не пускать два выкатаconcurrency.groupresource_group
Шаг при падении предыдущегоif: always()when: always + needs
Порядок задачsteps: внутри jobstages: + jobs

Здесь нет stages: в стиле Actions и нет uses: в стиле GitLab — именно из-за взаимной подмены ключей чужой пайплайн и не запускается. Скопировать deploy.yml в GitLab «и чуть поправить» нельзя; выше — то же поведение, собранное под валидные ключи GitLab. Проверяйте конфиг до мержа: gitlab-ci валидируется парсером CI Lint (в UI проекта), а линтером YAML — yamllint/gitlab-ci-lint в отдельной проверяющей стадии.

Инструменты под задачу

Всё выше — то, что вы прогоняете руками по SSH или прячете в стадию пайплайна. Если выкат живёт в .github/workflows / .gitlab-ci.yml / Jenkinsfile, корректную deploy-стадию — с условием «только main», отдельными secrets, шагом health-check и авто-откатом — быстрее собрать генератором, чем помнить валидные ключи каждой системы. В codestack.space есть AI CI/CD Generator: описываете проект по-человечески, выбираете систему и стек, ставите флаги (deploy только на main, кэш, matrix) — и получаете валидный конфиг именно под выбранную систему (теория — в CI/CD своими словами). Попробуйте собрать deploy-стадию прямо здесь:

Генератор CI/CD
Гость
Токены: 0 / 50 000
Гостевой лимит — 50 000 токенов. Зарегистрируйтесь, для пользователей лимит + 200 000 токенов.

Генератор снимает рутину «вспомнить, как в этой системе пишется условие ветки и где secrets»; логику атомарного выката, health-check и отката из этой статьи он оформляет корректными ключами пайплайна.

Чек-лист выката без боли (собрать в один прогон)

  • Релизы в отдельных папках `releases/`, `current` — симлинк; nginx на `current`.
  • Общие куски WordPress (`uploads`, `wp-config`/`.env`, кэш) в `shared/`, прилинкованы в релиз.
  • Права пользователя FPM на `current` и симлинки.
  • Health-check нового релиза (код 200 + нет фатала) **до** переключения.
  • Атомарная замена симлинка через `mv -T`.
  • Миграция БД обратно совместима и идемпотентна, запущена до переключения.
  • Откат = переключение `current` на прошлый релиз; прошлые релизы сохранены.
  • После отката проверили главную и ключевые страницы.

👤 Зарегистрируйтесь, чтобы добавить материал в закладки.