← к блогу

// девопс

Жму «опубликовать» — сайт пересобирается сам: repository_dispatch вместо деплой-хука

Обложка статьи: «Пушнул — собралось», repository_dispatch

Я жму "опубликовать". Пост лёг в базу, кэш сбросился, API отдаёт его сразу. А на ishabanov.ru — тишина. Контент новый, а сайт показывает позавчерашний. Никакой деплой сам себя не запустил. Главная мысль одним ударом: деплой-хук — это не фича платформы, а HTTP-запрос, который я просто не поленился отправить из того места, где первым узнал, что контент изменился.

Почему вообще нужно что-то пересобирать

Контент у меня впечён в Docker-образ, и это сознательное решение. Сайты тянут блог ещё на этапе сборки, клиенту фетчить нечего: один fetch в prebuild пишет снапшот posts.data.json, и дальше билд оперирует чистой статикой. Так живут SSG и OG-превью — бот Телеграма не исполняет JS, ему нужна готовая разметка: пустую SPA-оболочку он так и увидит пустой.

Расплата честная: контент — часть артефакта, снаружи его не подменишь. Хочешь новый пост на сайте — пересобирай образ. А у меня не Vercel: VPS, docker compose, nginx. Тут нет кнопки "redeploy on content change" и нет встроенного хука, который дёрнул бы за меня Netlify. Этого слоя у меня просто нет.

Кто пишет пост, тот и просит rebuild

Логика простая: момент истины знает ровно один компонент — контент-сервис. Только он понимает, что контент реально изменился. Значит, источником события должен быть он. GitHub под это даёт ровно один примитив — repository_dispatch. Это webhook-вход в Actions: делаешь POST на /repos/owner/repo/dispatches с любым event_type, и в репозитории просыпается workflow, подписанный на это событие. Программная кнопка "запусти мой деплой", которую можно дёрнуть откуда угодно, имея токен.

На каждую запись поста сервис вызывает revalidate(post.audiences). И тут всплывает кураторство: у поста есть audiences — ('main' | 'hire')[], и я пересобираю только те сайты, на которые он реально едет. Помечен только 'hire' — ishabanov.ru не трогаем. Внутри это POST на GitHub API с event_type 'content-updated' и client_payload, где лежит site.

await fetch(`https://api.github.com/repos/${repo}/dispatches`, {
  method: 'POST',
  headers: { authorization: `Bearer ${token}`, accept: 'application/vnd.github+json' },
  body: JSON.stringify({ event_type: 'content-updated', client_payload: { site } }),
});
// GitHub отвечает 204 на успешный dispatch

Принцип — fire-and-forget. Диспатч обёрнут в try/catch, любая ошибка превращается в warn в логах и не валит запись поста. Нет токена или не задан репозиторий — сервис пишет "пропуск" и идёт дальше. Публикация не должна зависеть от того, доступен ли в эту секунду GitHub. Контент уже в базе, rebuild — отдельная отказоустойчивая забота.

Воркфлоу на той стороне слушает

В репозитории сайта deploy-workflow подписан на три триггера: push в main (поменялся код), workflow_dispatch (запустить руками) и repository_dispatch с типом content-updated — это как раз сигнал от сервиса. Дальше один шаг: appleboy/ssh-action заходит на VPS, пишет server/.env прямо на машине из GitHub Secrets через heredoc под umask 077 (файл читает только владелец), делает git pull --ff-only и docker compose up. Без отдельного раннера и без агента на сервере.

on:
  push:
    branches: [main]
  repository_dispatch:
    types: [content-updated]
  workflow_dispatch:

Подвох, на который я слил вечер

Первая версия делала docker compose up -d --build на любой триггер — и на dispatch сайт не обновлялся. Я перечитывал revalidate, проверял, что 204 приходит, что workflow стартует. Всё зелёное — контент старый. Наивно думал, что раз билд проходит, то и снапшот свежий. Дело оказалось в Docker-кэше. На publish-событии в git-репозитории сайта не меняется ни один файл: код тот же, Dockerfile тот же. Для Docker это значит, что все слои валидны, и он радостно переиспользует закэшированный слой со старым snapshot внутри. prebuild с фетчем просто не выполняется заново — слой уже собран.

Docker честно пересобирает то, что изменилось. Беда в том, что при публикации поста на стороне сайта не меняется ничего — кроме данных на другом конце сети, о которых кэш слоёв ничего не знает.

Лечится развилкой по типу события. На repository_dispatch — и только на нём — форсим docker compose build --no-cache web. Это выкидывает кэш именно для веб-сервиса: prebuild-фетч (fetch-content.mjs) выполняется по новой, ходит на content.ishabanov.ru за свежим срезом site=main и перезаписывает posts.data.json в образе. На обычном push --no-cache не нужен — там поменялся код, Docker и так инвалидирует слои сам.

if [ "${DEPLOY_EVENT}" = "repository_dispatch" ]; then
  docker compose build --no-cache web
fi
docker compose up -d --build

А если сервис в момент пересборки лежит? Срабатывает фоллбэк: fetch-content.mjs ловит ошибку, логирует warning и выходит с кодом 0, оставляя уже закоммиченный snapshot. Билд проходит на старом контенте, но проходит. Сеть не должна ронять деплой — та же страховка, что и fire-and-forget на стороне сервиса, только с другого конца.

В итоге получается замкнутая петля без единого ручного шага: пишу пост → сервис кладёт его в Turso и стреляет dispatch'ем в нужные сайты по audiences → GitHub Actions заходит по SSH, форсит no-cache билд web → prebuild забирает свежий контент → compose поднимает новый образ. Никакой магии хостинга — просто я отправил запрос оттуда, где впервые узнал, что что-то изменилось.

P.S. В продолжение — почему контент я гружу прямо на билде, без всякого SSR, и как при этом отдать ботам нормальные превью, чтобы ссылка в Телеграме не выглядела пустой.

Есть похожая задача?

Расскажите — вернусь с оценкой в тот же день.

Обсудить проект

// читать дальше

Кадр из клипа про то, почему ИИ-агенты рисуют серые интерфейсы
0:36
// видео

Почему ИИ-агенты рисуют одинаково серые интерфейсы

Обложка статьи: «Claude Design в проде»
// дизайн6 мин

Как использовать Claude Design в проде

Я разработчик, дизайнер из меня никакой. Но сайт, который ты читаешь, выглядит как надо — дизайн я собрал с Claude, руками не рисовал. Показываю весь путь и артефакты, которые реально пошли в работу.

Обложка статьи: «1 блог, 2 сайта, 1 сервис»
// архитектура8 мин

Два сайта — один блог: как я не уронил SEO, сливая контент в один сервис

Слил блоги двух сайтов в один сервис на Hono и Turso. База оказалась простой частью, вся коварность — в доставке: контент должен приезжать на билде, потому что при фетче на клиенте превью-боты ловят пустую страницу.