[{"data":1,"prerenderedAt":27},["ShallowReactive",2],{"project:alexeslm-auto-deploy":3},{"html":4,"toc":5},"\u003Cp>При ручном обновлении сайта разработчик каждый раз повторяет одни и те же действия: загружает изменения, устанавливает зависимости, собирает приложение и проверяет результат. Автодеплой выполняет эту последовательность по заранее настроенному сценарию. На alexeslm.ru его запускает подтверждение изменений в GitHub. В кейсе разберу, как связаны сборка, установка и проверка новой версии и как воспроизвести этот процесс для своего сайта.\u003C\u002Fp>\n\u003Ch2 id=\"section-1\">Новая версия собирается, пока работает предыдущая\u003C\u002Fh2>\n\u003Cp>Главное разделение в этой схеме — подготовка версии и её запуск. После слияния PR сервер получает конкретный коммит и собирает его в отдельном каталоге. Работающий сайт продолжает использовать прежний релиз. Ошибка установки зависимостей или сборки останавливает обновление до переключения.\u003C\u002Fp>\n\u003Cp>Готовая сборка тоже получает свой каталог. Ссылка \u003Ccode>current\u003C\u002Fcode> указывает на версию, из которой запускается сайт. Перед перезапуском её переводят на новый релиз; предыдущий каталог остаётся на месте для возможного возврата.\u003C\u002Fp>\n\u003Cp>На примере каталогов это выглядит так:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-text\">\u002Fsrv\u002Fcontent-site\u002F\n  releases\u002F\n    release-previous\u002F\n      server\u002Findex.mjs\n      public\u002F\n    release-new\u002F\n      server\u002Findex.mjs\n      public\u002F\n  current -&gt; releases\u002Frelease-previous\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>В начале \u003Ccode>current\u003C\u002Fcode> указывает на \u003Ccode>release-previous\u003C\u002Fcode>. После подготовки \u003Ccode>release-new\u003C\u002Fcode> меняется ссылка и перезапускается процесс. Установщик проверяет запуск, чтобы решить, оставлять ли новую версию. Ниже покажу весь сценарий, включая возврат предыдущей версии при неудачной локальной проверке.\u003C\u002Fp>\n\u003Cp>Примеры рассчитаны на уже подготовленный Linux-сервер с первым рабочим релизом. Настройки systemd, Nginx и прав пользователя приведены в разделе «Что подготовить на сервере». Для запуска сценария их нужно выполнить заранее.\u003C\u002Fp>\n\u003Ch2 id=\"section-2\">Передать серверу именно тот коммит, который приняли\u003C\u002Fh2>\n\u003Cp>Для alexeslm.ru отправной точкой служит слияние PR в \u003Ccode>master\u003C\u002Fcode>. Workflow получает событие \u003Ccode>pull_request\u003C\u002Fcode> с типом \u003Ccode>closed\u003C\u002Fcode>, затем проверяет \u003Ccode>merged == true\u003C\u002Fcode>. Простое закрытие PR и обычный push в ветку выкладку не запускают.\u003C\u002Fp>\n\u003Cp>Вместе с командой сервер получает \u003Ccode>merge_commit_sha\u003C\u002Fcode> — идентификатор принятого коммита. Так запуск связан с конкретной версией кода. Если за время ожидания появятся новые изменения, сценарий сможет обнаружить, что его версия устарела.\u003C\u002Fp>\n\u003Cp>В \u003Ccode>.github\u002Fworkflows\u002Fdeploy.yml\u003C\u002Fcode> это можно записать так. Вариант рассчитан на PR из веток того же репозитория:\u003C\u002Fp>\n\u003Cpre tabindex=\"0\" aria-label=\"Блок кода: yaml\">\u003Ccode class=\"hljs language-yaml\">\u003Cspan class=\"hljs-attr\">name:\u003C\u002Fspan> \u003Cspan class=\"hljs-string\">Deploy\u003C\u002Fspan> \u003Cspan class=\"hljs-string\">site\u003C\u002Fspan>\n\u003Cspan class=\"hljs-attr\">on:\u003C\u002Fspan>\n  \u003Cspan class=\"hljs-attr\">pull_request:\u003C\u002Fspan>\n    \u003Cspan class=\"hljs-attr\">branches:\u003C\u002Fspan> [\u003Cspan class=\"hljs-string\">master\u003C\u002Fspan>]\n    \u003Cspan class=\"hljs-attr\">types:\u003C\u002Fspan> [\u003Cspan class=\"hljs-string\">closed\u003C\u002Fspan>]\n\u003Cspan class=\"hljs-attr\">permissions:\u003C\u002Fspan>\n  \u003Cspan class=\"hljs-attr\">contents:\u003C\u002Fspan> \u003Cspan class=\"hljs-string\">read\u003C\u002Fspan>\n\u003Cspan class=\"hljs-attr\">concurrency:\u003C\u002Fspan>\n  \u003Cspan class=\"hljs-attr\">group:\u003C\u002Fspan> \u003Cspan class=\"hljs-string\">production\u003C\u002Fspan>\n  \u003Cspan class=\"hljs-attr\">cancel-in-progress:\u003C\u002Fspan> \u003Cspan class=\"hljs-literal\">false\u003C\u002Fspan>\n\u003Cspan class=\"hljs-attr\">jobs:\u003C\u002Fspan>\n  \u003Cspan class=\"hljs-attr\">deploy:\u003C\u002Fspan>\n    \u003Cspan class=\"hljs-attr\">if:\u003C\u002Fspan> \u003Cspan class=\"hljs-string\">github.event.pull_request.merged\u003C\u002Fspan> \u003Cspan class=\"hljs-string\">==\u003C\u002Fspan> \u003Cspan class=\"hljs-literal\">true\u003C\u002Fspan>\n    \u003Cspan class=\"hljs-attr\">runs-on:\u003C\u002Fspan> \u003Cspan class=\"hljs-string\">ubuntu-latest\u003C\u002Fspan>\n    \u003Cspan class=\"hljs-attr\">timeout-minutes:\u003C\u002Fspan> \u003Cspan class=\"hljs-number\">45\u003C\u002Fspan>\n    \u003Cspan class=\"hljs-attr\">environment:\u003C\u002Fspan> \u003Cspan class=\"hljs-string\">production\u003C\u002Fspan>\n    \u003Cspan class=\"hljs-attr\">steps:\u003C\u002Fspan>\n      \u003Cspan class=\"hljs-bullet\">-\u003C\u002Fspan> \u003Cspan class=\"hljs-attr\">uses:\u003C\u002Fspan> \u003Cspan class=\"hljs-string\">actions\u002Fcheckout@11bd71901bbe5b1630ceea73d27597364c9af683\u003C\u002Fspan>\n        \u003Cspan class=\"hljs-attr\">with:\u003C\u002Fspan>\n          \u003Cspan class=\"hljs-attr\">ref:\u003C\u002Fspan> \u003Cspan class=\"hljs-string\">${{\u003C\u002Fspan> \u003Cspan class=\"hljs-string\">github.event.pull_request.merge_commit_sha\u003C\u002Fspan> \u003Cspan class=\"hljs-string\">}}\u003C\u002Fspan>\n          \u003Cspan class=\"hljs-attr\">persist-credentials:\u003C\u002Fspan> \u003Cspan class=\"hljs-literal\">false\u003C\u002Fspan>\n      \u003Cspan class=\"hljs-bullet\">-\u003C\u002Fspan> \u003Cspan class=\"hljs-attr\">name:\u003C\u002Fspan> \u003Cspan class=\"hljs-string\">Run\u003C\u002Fspan> \u003Cspan class=\"hljs-string\">deployment\u003C\u002Fspan> \u003Cspan class=\"hljs-string\">on\u003C\u002Fspan> \u003Cspan class=\"hljs-string\">server\u003C\u002Fspan>\n        \u003Cspan class=\"hljs-attr\">shell:\u003C\u002Fspan> \u003Cspan class=\"hljs-string\">bash\u003C\u002Fspan>\n        \u003Cspan class=\"hljs-attr\">env:\u003C\u002Fspan>\n          \u003Cspan class=\"hljs-attr\">DEPLOY_HOST:\u003C\u002Fspan> \u003Cspan class=\"hljs-string\">${{\u003C\u002Fspan> \u003Cspan class=\"hljs-string\">secrets.DEPLOY_HOST\u003C\u002Fspan> \u003Cspan class=\"hljs-string\">}}\u003C\u002Fspan>\n          \u003Cspan class=\"hljs-attr\">DEPLOY_USER:\u003C\u002Fspan> \u003Cspan class=\"hljs-string\">${{\u003C\u002Fspan> \u003Cspan class=\"hljs-string\">secrets.DEPLOY_USER\u003C\u002Fspan> \u003Cspan class=\"hljs-string\">}}\u003C\u002Fspan>\n          \u003Cspan class=\"hljs-attr\">DEPLOY_REPO:\u003C\u002Fspan> \u003Cspan class=\"hljs-string\">${{\u003C\u002Fspan> \u003Cspan class=\"hljs-string\">secrets.DEPLOY_REPO\u003C\u002Fspan> \u003Cspan class=\"hljs-string\">}}\u003C\u002Fspan>\n          \u003Cspan class=\"hljs-attr\">DEPLOY_KEY:\u003C\u002Fspan> \u003Cspan class=\"hljs-string\">${{\u003C\u002Fspan> \u003Cspan class=\"hljs-string\">secrets.DEPLOY_KEY\u003C\u002Fspan> \u003Cspan class=\"hljs-string\">}}\u003C\u002Fspan>\n          \u003Cspan class=\"hljs-attr\">DEPLOY_HOST_KEYS:\u003C\u002Fspan> \u003Cspan class=\"hljs-string\">${{\u003C\u002Fspan> \u003Cspan class=\"hljs-string\">secrets.DEPLOY_HOST_KEYS\u003C\u002Fspan> \u003Cspan class=\"hljs-string\">}}\u003C\u002Fspan>\n          \u003Cspan class=\"hljs-attr\">DEPLOY_SHA:\u003C\u002Fspan> \u003Cspan class=\"hljs-string\">${{\u003C\u002Fspan> \u003Cspan class=\"hljs-string\">github.event.pull_request.merge_commit_sha\u003C\u002Fspan> \u003Cspan class=\"hljs-string\">}}\u003C\u002Fspan>\n        \u003Cspan class=\"hljs-attr\">run:\u003C\u002Fspan> \u003Cspan class=\"hljs-string\">|\n          set -euo pipefail\n          : \"${DEPLOY_HOST:?}\" \"${DEPLOY_USER:?}\" \"${DEPLOY_REPO:?}\"\n          : \"${DEPLOY_KEY:?}\" \"${DEPLOY_HOST_KEYS:?}\"\n          ssh_dir=$(mktemp -d \"$RUNNER_TEMP\u002Fsite-ssh.XXXXXX\")\n          trap 'rm -rf -- \"$ssh_dir\"' EXIT\n          printf '%s\\n' \"$DEPLOY_KEY\" &gt; \"$ssh_dir\u002Fkey\"\n          printf '%s\\n' \"$DEPLOY_HOST_KEYS\" &gt; \"$ssh_dir\u002Fknown_hosts\"\n          chmod 600 \"$ssh_dir\u002F\"*\n          printf -v command 'bash -s -- %q %q' \"$DEPLOY_REPO\" \"$DEPLOY_SHA\"\n          status=0\n          ssh -i \"$ssh_dir\u002Fkey\" -o BatchMode=yes -o IdentitiesOnly=yes \\\n            -o StrictHostKeyChecking=yes \\\n            -o UserKnownHostsFile=\"$ssh_dir\u002Fknown_hosts\" \\\n            \"$DEPLOY_USER@$DEPLOY_HOST\" \"$command\" &lt; deploy\u002Fdeploy.sh || status=$?\n          if (( status == 21 )); then\n            echo 'Skipped: a newer commit is already in master.'\n          else\n            exit \"$status\"\n          fi\u003C\u002Fspan>\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Workflow берёт \u003Ccode>deploy\u002Fdeploy.sh\u003C\u002Fcode> из принятого коммита и передаёт его по SSH через stdin. Абсолютный путь к репозиторию на сервере и SHA идут аргументами с экранированием для Bash. Серверный скрипт приведён ниже, после шагов сборки и проверки.\u003C\u002Fp>\n\u003Cp>Для подключения в GitHub создаётся environment \u003Ccode>production\u003C\u002Fcode> с пятью secrets: \u003Ccode>DEPLOY_HOST\u003C\u002Fcode> — SSH-хост, \u003Ccode>DEPLOY_USER\u003C\u002Fcode> — пользователь выкладки, \u003Ccode>DEPLOY_REPO\u003C\u002Fcode> — путь к репозиторию, \u003Ccode>DEPLOY_KEY\u003C\u002Fcode> — приватный ключ входа, \u003Ccode>DEPLOY_HOST_KEYS\u003C\u002Fcode> — проверенная запись \u003Ccode>known_hosts\u003C\u002Fcode> сервера. Если у environment включено обязательное одобрение, запуск дождётся его.\u003C\u002Fp>\n\u003Cp>Группа \u003Ccode>concurrency\u003C\u002Fcode> не даёт двум production-запускам выполняться одновременно. \u003Ccode>cancel-in-progress: false\u003C\u002Fcode> сохраняет уже работающий запуск; ожидающий GitHub может заменить более новым. Это существенно для переключения релизов: начатый процесс должен закончить свой сценарий.\u003C\u002Fp>\n\u003Ch2 id=\"section-3\">Собрать релиз вне работающего приложения\u003C\u002Fh2>\n\u003Cp>На сервере исходники сначала обновляются через \u003Ccode>git fetch\u003C\u002Fcode>. Затем \u003Ccode>git worktree\u003C\u002Fcode> создаёт отдельную рабочую копию нужного SHA. Основной checkout сохраняет своё состояние, а сборка получает собственный каталог. В примере незакоммиченные изменения в основном checkout останавливают обновление.\u003C\u002Fp>\n\u003Cp>Внутри worktree выполняется \u003Ccode>npm ci --include=dev\u003C\u002Fcode>: зависимости устанавливаются по \u003Ccode>package-lock.json\u003C\u002Fcode>, включая инструменты сборки TypeScript и CSS. Работающий Node-процесс использует готовый релиз, поэтому эта установка не меняет его файлы.\u003C\u002Fp>\n\u003Cp>На alexeslm.ru команда \u003Ccode>npm run release\u003C\u002Fcode> проверяет контент, собирает приложение, выполняет typecheck и упаковывает \u003Ccode>.output\u003C\u002Fcode>. Для примера объединим эти действия в \u003Ccode>deploy\u002Frelease.sh\u003C\u002Fcode>:\u003C\u002Fp>\n\u003Cpre tabindex=\"0\" aria-label=\"Блок кода: bash\">\u003Ccode class=\"hljs language-bash\">\u003Cspan class=\"hljs-meta\">#!\u002Fusr\u002Fbin\u002Fenv bash\u003C\u002Fspan>\n\u003Cspan class=\"hljs-built_in\">set\u003C\u002Fspan> -euo pipefail\n\u003Cspan class=\"hljs-built_in\">export\u003C\u002Fspan> SITE_INCLUDE_REVIEW=\u003Cspan class=\"hljs-literal\">false\u003C\u002Fspan>\n\u003Cspan class=\"hljs-built_in\">export\u003C\u002Fspan> SITE_INDEXABLE=\u003Cspan class=\"hljs-literal\">true\u003C\u002Fspan>\n\u003Cspan class=\"hljs-built_in\">export\u003C\u002Fspan> SITE_URL=https:\u002F\u002Fexample.com\nnpm run \u003Cspan class=\"hljs-built_in\">test\u003C\u002Fspan>:content\nnpm run build\nnpm run typecheck\n\u003Cspan class=\"hljs-built_in\">mkdir\u003C\u002Fspan> -p dist\ntar -czf dist\u002Fsite.tar.gz -C .output .\n(\u003Cspan class=\"hljs-built_in\">cd\u003C\u002Fspan> dist &amp;&amp; \u003Cspan class=\"hljs-built_in\">sha256sum\u003C\u002Fspan> site.tar.gz &gt; site.tar.gz.sha256)\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>\u003Ccode>test:content\u003C\u002Fcode> и \u003Ccode>typecheck\u003C\u002Fcode> здесь — команды проекта, а \u003Ccode>build\u003C\u002Fcode> запускает Nuxt и его \u003Ccode>prebuild\u003C\u002Fcode> с компиляцией материалов. Для своего проекта определите соответствующие проверки в \u003Ccode>package.json\u003C\u002Fcode>. \u003Ccode>SITE_URL\u003C\u002Fcode> замените каноническим доменом: часть настроек предварительно подготовленных страниц формируется во время сборки.\u003C\u002Fp>\n\u003Cp>\u003Ccode>SITE_INCLUDE_REVIEW=false\u003C\u002Fcode> исключает редакционные материалы из публичной сборки. Архив получает содержимое \u003Ccode>.output\u003C\u002Fcode>: серверный код, его зависимости и публичные ресурсы. Рядом сохраняется SHA256, по которому установщик проверит целостность архива перед распаковкой.\u003C\u002Fp>\n\u003Cp>На этом этапе есть файл релиза. Работающий сайт всё ещё использует прежнюю версию.\u003C\u002Fp>\n\u003Ch2 id=\"section-4\">За время сборки мог появиться следующий PR\u003C\u002Fh2>\n\u003Cp>Допустим, один PR уже запустил сборку, а в \u003Ccode>master\u003C\u002Fcode> успели слить следующий. Если установить первую версию без проверки, сайт получит коммит, который уже перестал быть последним.\u003C\u002Fp>\n\u003Cp>Сценарий alexeslm.ru сравнивает переданный SHA с вершиной ветки дважды: перед сборкой и перед установкой. При несовпадении пропускает выкладку. В примере это код завершения \u003Ccode>21\u003C\u002Fcode>; workflow обрабатывает его как пропуск, остальные ошибки делают job неуспешной.\u003C\u002Fp>\n\u003Cp>Группа \u003Ccode>concurrency\u003C\u002Fcode> действует внутри GitHub. На сервере весь цикл дополнительно закрывает \u003Ccode>flock\u003C\u002Fcode>, поскольку тот же сценарий можно вызвать вручную. Установщик проекта также имеет собственную блокировку этапа установки.\u003C\u002Fp>\n\u003Cp>Между последней проверкой SHA и переключением всё равно может прийти новый merge. В таком случае последующий запуск подготовит следующую версию. Две проверки позволяют отсеять устаревший запуск до установки, когда новый коммит уже известен серверу.\u003C\u002Fp>\n\u003Ch2 id=\"section-5\">Переключить версию и проверить запуск\u003C\u002Fh2>\n\u003Cp>Перед переключением сценарий запоминает, куда указывает \u003Ccode>current\u003C\u002Fcode>. Архив распаковывается в новый каталог внутри \u003Ccode>releases\u003C\u002Fcode>, затем рядом создаётся новая ссылка. Команда \u003Ccode>mv -Tf\u003C\u002Fcode> заменяет ею \u003Ccode>current\u003C\u002Fcode>, после чего systemd перезапускает Node-процесс.\u003C\u002Fp>\n\u003Cp>Для локальной проверки в примере используется Nuxt-обработчик \u003Ccode>server\u002Froutes\u002Fhealthz.get.ts\u003C\u002Fcode>:\u003C\u002Fp>\n\u003Cpre tabindex=\"0\" aria-label=\"Блок кода: ts\">\u003Ccode class=\"hljs language-ts\">\u003Cspan class=\"hljs-keyword\">export\u003C\u002Fspan> \u003Cspan class=\"hljs-keyword\">default\u003C\u002Fspan> \u003Cspan class=\"hljs-title function_\">defineEventHandler\u003C\u002Fspan>(\u003Cspan class=\"hljs-function\">() =&gt;\u003C\u002Fspan> ({ \u003Cspan class=\"hljs-attr\">status\u003C\u002Fspan>: \u003Cspan class=\"hljs-string\">'ok'\u003C\u002Fspan> }))\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>После перезапуска скрипт ждёт активную службу и успешный ответ \u003Ccode>\u002Fhealthz\u003C\u002Fcode>. Если процесс не запустился или endpoint недоступен, он возвращает ссылку на предыдущий каталог, снова перезапускает службу и повторяет проверку. Даже удачный возврат завершает деплой с ошибкой: новая версия не установлена.\u003C\u002Fp>\n\u003Cp>Теперь соберём подготовку и установку в \u003Ccode>deploy\u002Fdeploy.sh\u003C\u002Fcode>. Файл использует \u003Ccode>release.sh\u003C\u002Fcode> из предыдущего раздела, существующий \u003Ccode>current\u003C\u002Fcode> и права из раздела настройки сервера:\u003C\u002Fp>\n\u003Cpre tabindex=\"0\" aria-label=\"Блок кода: bash\">\u003Ccode class=\"hljs language-bash\">\u003Cspan class=\"hljs-meta\">#!\u002Fusr\u002Fbin\u002Fenv bash\u003C\u002Fspan>\n\u003Cspan class=\"hljs-built_in\">set\u003C\u002Fspan> -euo pipefail\nrepo=$(\u003Cspan class=\"hljs-built_in\">realpath\u003C\u002Fspan> \u003Cspan class=\"hljs-string\">\"\u003Cspan class=\"hljs-variable\">${1:?Repository path required}\u003C\u002Fspan>\"\u003C\u002Fspan>)\nsha=\u003Cspan class=\"hljs-variable\">${2:?Commit SHA required}\u003C\u002Fspan>\n[[ \u003Cspan class=\"hljs-string\">\"\u003Cspan class=\"hljs-variable\">$sha\u003C\u002Fspan>\"\u003C\u002Fspan> =~ ^[0-9a-f]{40}$ ]]\n[[ $(\u003Cspan class=\"hljs-built_in\">id\u003C\u002Fspan> -u) != 0 ]]\nroot=\u003Cspan class=\"hljs-variable\">${DEPLOY_ROOT:-\u002Fsrv\u002Fcontent-site}\u003C\u002Fspan>\nservice=content-site.service\nhealth_url=http:\u002F\u002F127.0.0.1:3100\u002Fhealthz\n\u003Cspan class=\"hljs-built_in\">export\u003C\u002Fspan> GIT_TERMINAL_PROMPT=0\n\u003Cspan class=\"hljs-built_in\">export\u003C\u002Fspan> GIT_SSH_COMMAND=\u003Cspan class=\"hljs-string\">'ssh -o BatchMode=yes -o StrictHostKeyChecking=yes'\u003C\u002Fspan>\n\u003Cspan class=\"hljs-built_in\">cd\u003C\u002Fspan> \u003Cspan class=\"hljs-string\">\"\u003Cspan class=\"hljs-variable\">$repo\u003C\u002Fspan>\"\u003C\u002Fspan>\n[[ $(git rev-parse --show-toplevel) == \u003Cspan class=\"hljs-string\">\"\u003Cspan class=\"hljs-variable\">$repo\u003C\u002Fspan>\"\u003C\u002Fspan> ]]\n\u003Cspan class=\"hljs-built_in\">exec\u003C\u002Fspan> 9&gt;\u003Cspan class=\"hljs-string\">\"\u003Cspan class=\"hljs-subst\">$(git rev-parse --git-common-dir)\u003C\u002Fspan>\u002Fdeploy.lock\"\u003C\u002Fspan>\nflock -n 9 || { \u003Cspan class=\"hljs-built_in\">echo\u003C\u002Fspan> \u003Cspan class=\"hljs-string\">'Deployment already running'\u003C\u002Fspan> &gt;&amp;2; \u003Cspan class=\"hljs-built_in\">exit\u003C\u002Fspan> 1; }\n[[ -z $(git status --porcelain) ]] || { \u003Cspan class=\"hljs-built_in\">echo\u003C\u002Fspan> \u003Cspan class=\"hljs-string\">'Repository has local changes'\u003C\u002Fspan> &gt;&amp;2; \u003Cspan class=\"hljs-built_in\">exit\u003C\u002Fspan> 20; }\n\u003Cspan class=\"hljs-built_in\">sudo\u003C\u002Fspan> -n -l \u002Fusr\u002Fbin\u002Fsystemctl restart \u003Cspan class=\"hljs-string\">\"\u003Cspan class=\"hljs-variable\">$service\u003C\u002Fspan>\"\u003C\u002Fspan> &gt;\u002Fdev\u002Fnull\n\n\u003Cspan class=\"hljs-function\">\u003Cspan class=\"hljs-title\">check_commit\u003C\u002Fspan>\u003C\u002Fspan>() {\n  git fetch --no-tags origin refs\u002Fheads\u002Fmaster:refs\u002Fremotes\u002Forigin\u002Fmaster\n  [[ $(git rev-parse origin\u002Fmaster) == \u003Cspan class=\"hljs-string\">\"\u003Cspan class=\"hljs-variable\">$sha\u003C\u002Fspan>\"\u003C\u002Fspan> ]] || \u003Cspan class=\"hljs-built_in\">return\u003C\u002Fspan> 21\n}\ncheck_commit\nbuild=$(\u003Cspan class=\"hljs-built_in\">mktemp\u003C\u002Fspan> -d)\n\u003Cspan class=\"hljs-function\">\u003Cspan class=\"hljs-title\">cleanup\u003C\u002Fspan>\u003C\u002Fspan>() {\n  git -C \u003Cspan class=\"hljs-string\">\"\u003Cspan class=\"hljs-variable\">$repo\u003C\u002Fspan>\"\u003C\u002Fspan> worktree remove --force \u003Cspan class=\"hljs-string\">\"\u003Cspan class=\"hljs-variable\">$build\u003C\u002Fspan>\u002Fsource\"\u003C\u002Fspan> 2&gt;\u002Fdev\u002Fnull || \u003Cspan class=\"hljs-literal\">true\u003C\u002Fspan>\n  \u003Cspan class=\"hljs-built_in\">rm\u003C\u002Fspan> -rf -- \u003Cspan class=\"hljs-string\">\"\u003Cspan class=\"hljs-variable\">$build\u003C\u002Fspan>\"\u003C\u002Fspan>\n}\n\u003Cspan class=\"hljs-built_in\">trap\u003C\u002Fspan> cleanup EXIT\ngit worktree add --detach \u003Cspan class=\"hljs-string\">\"\u003Cspan class=\"hljs-variable\">$build\u003C\u002Fspan>\u002Fsource\"\u003C\u002Fspan> \u003Cspan class=\"hljs-string\">\"\u003Cspan class=\"hljs-variable\">$sha\u003C\u002Fspan>\"\u003C\u002Fspan>\n\u003Cspan class=\"hljs-built_in\">cd\u003C\u002Fspan> \u003Cspan class=\"hljs-string\">\"\u003Cspan class=\"hljs-variable\">$build\u003C\u002Fspan>\u002Fsource\"\u003C\u002Fspan>\nnpm ci --include=dev\nbash deploy\u002Frelease.sh\n(\u003Cspan class=\"hljs-built_in\">cd\u003C\u002Fspan> dist &amp;&amp; \u003Cspan class=\"hljs-built_in\">sha256sum\u003C\u002Fspan> --check site.tar.gz.sha256)\ncheck_commit\n\nprevious=$(\u003Cspan class=\"hljs-built_in\">readlink\u003C\u002Fspan> -f \u003Cspan class=\"hljs-string\">\"\u003Cspan class=\"hljs-variable\">$root\u003C\u002Fspan>\u002Fcurrent\"\u003C\u002Fspan>)\n[[ -d \u003Cspan class=\"hljs-string\">\"\u003Cspan class=\"hljs-variable\">$previous\u003C\u002Fspan>\"\u003C\u002Fspan> ]] \u003Cspan class=\"hljs-comment\"># Первый рабочий релиз устанавливается заранее.\u003C\u002Fspan>\nrelease=$(\u003Cspan class=\"hljs-built_in\">mktemp\u003C\u002Fspan> -d \u003Cspan class=\"hljs-string\">\"\u003Cspan class=\"hljs-variable\">$root\u003C\u002Fspan>\u002Freleases\u002F\u003Cspan class=\"hljs-variable\">${sha:0:12}\u003C\u002Fspan>-XXXXXX\"\u003C\u002Fspan>)\ntar -xzf dist\u002Fsite.tar.gz -C \u003Cspan class=\"hljs-string\">\"\u003Cspan class=\"hljs-variable\">$release\u003C\u002Fspan>\"\u003C\u002Fspan> --no-same-owner\n\u003Cspan class=\"hljs-built_in\">test\u003C\u002Fspan> -f \u003Cspan class=\"hljs-string\">\"\u003Cspan class=\"hljs-variable\">$release\u003C\u002Fspan>\u002Fserver\u002Findex.mjs\"\u003C\u002Fspan>\n\n\u003Cspan class=\"hljs-function\">\u003Cspan class=\"hljs-title\">switch_release\u003C\u002Fspan>\u003C\u002Fspan>() {\n  \u003Cspan class=\"hljs-built_in\">local\u003C\u002Fspan> \u003Cspan class=\"hljs-built_in\">link\u003C\u002Fspan>\n  \u003Cspan class=\"hljs-built_in\">link\u003C\u002Fspan>=\u003Cspan class=\"hljs-string\">\"\u003Cspan class=\"hljs-variable\">$root\u003C\u002Fspan>\u002Fcurrent.\u003Cspan class=\"hljs-variable\">$RANDOM\u003C\u002Fspan>.\u003Cspan class=\"hljs-variable\">$RANDOM\u003C\u002Fspan>\"\u003C\u002Fspan>\n  \u003Cspan class=\"hljs-built_in\">ln\u003C\u002Fspan> -sT \u003Cspan class=\"hljs-string\">\"\u003Cspan class=\"hljs-variable\">$1\u003C\u002Fspan>\"\u003C\u002Fspan> \u003Cspan class=\"hljs-string\">\"\u003Cspan class=\"hljs-variable\">$link\u003C\u002Fspan>\"\u003C\u002Fspan>\n  \u003Cspan class=\"hljs-built_in\">mv\u003C\u002Fspan> -Tf \u003Cspan class=\"hljs-string\">\"\u003Cspan class=\"hljs-variable\">$link\u003C\u002Fspan>\"\u003C\u002Fspan> \u003Cspan class=\"hljs-string\">\"\u003Cspan class=\"hljs-variable\">$root\u003C\u002Fspan>\u002Fcurrent\"\u003C\u002Fspan>\n}\n\u003Cspan class=\"hljs-function\">\u003Cspan class=\"hljs-title\">healthy\u003C\u002Fspan>\u003C\u002Fspan>() {\n  \u003Cspan class=\"hljs-keyword\">for\u003C\u002Fspan> attempt \u003Cspan class=\"hljs-keyword\">in\u003C\u002Fspan> {1..15}; \u003Cspan class=\"hljs-keyword\">do\u003C\u002Fspan>\n    \u003Cspan class=\"hljs-keyword\">if\u003C\u002Fspan> systemctl is-active --quiet \u003Cspan class=\"hljs-string\">\"\u003Cspan class=\"hljs-variable\">$service\u003C\u002Fspan>\"\u003C\u002Fspan> \\\n      &amp;&amp; curl --fail --silent --max-time 2 \u003Cspan class=\"hljs-string\">\"\u003Cspan class=\"hljs-variable\">$health_url\u003C\u002Fspan>\"\u003C\u002Fspan> &gt;\u002Fdev\u002Fnull; \u003Cspan class=\"hljs-keyword\">then\u003C\u002Fspan>\n      \u003Cspan class=\"hljs-built_in\">return\u003C\u002Fspan> 0\n    \u003Cspan class=\"hljs-keyword\">fi\u003C\u002Fspan>\n    \u003Cspan class=\"hljs-built_in\">sleep\u003C\u002Fspan> 1\n  \u003Cspan class=\"hljs-keyword\">done\u003C\u002Fspan>\n  \u003Cspan class=\"hljs-built_in\">return\u003C\u002Fspan> 1\n}\n\nswitch_release \u003Cspan class=\"hljs-string\">\"\u003Cspan class=\"hljs-variable\">$release\u003C\u002Fspan>\"\u003C\u002Fspan>\n\u003Cspan class=\"hljs-keyword\">if\u003C\u002Fspan> \u003Cspan class=\"hljs-built_in\">sudo\u003C\u002Fspan> -n \u002Fusr\u002Fbin\u002Fsystemctl restart \u003Cspan class=\"hljs-string\">\"\u003Cspan class=\"hljs-variable\">$service\u003C\u002Fspan>\"\u003C\u002Fspan> &amp;&amp; healthy; \u003Cspan class=\"hljs-keyword\">then\u003C\u002Fspan>\n  \u003Cspan class=\"hljs-built_in\">printf\u003C\u002Fspan> \u003Cspan class=\"hljs-string\">'Deployed %s\\n'\u003C\u002Fspan> \u003Cspan class=\"hljs-string\">\"\u003Cspan class=\"hljs-variable\">$sha\u003C\u002Fspan>\"\u003C\u002Fspan>\n\u003Cspan class=\"hljs-keyword\">else\u003C\u002Fspan>\n  \u003Cspan class=\"hljs-built_in\">echo\u003C\u002Fspan> \u003Cspan class=\"hljs-string\">'New release failed; restoring previous release'\u003C\u002Fspan> &gt;&amp;2\n  switch_release \u003Cspan class=\"hljs-string\">\"\u003Cspan class=\"hljs-variable\">$previous\u003C\u002Fspan>\"\u003C\u002Fspan>\n  \u003Cspan class=\"hljs-built_in\">sudo\u003C\u002Fspan> -n \u002Fusr\u002Fbin\u002Fsystemctl restart \u003Cspan class=\"hljs-string\">\"\u003Cspan class=\"hljs-variable\">$service\u003C\u002Fspan>\"\u003C\u002Fspan>\n  healthy || \u003Cspan class=\"hljs-built_in\">echo\u003C\u002Fspan> \u003Cspan class=\"hljs-string\">'Rollback also needs attention'\u003C\u002Fspan> &gt;&amp;2\n  \u003Cspan class=\"hljs-built_in\">exit\u003C\u002Fspan> 1\n\u003Cspan class=\"hljs-keyword\">fi\u003C\u002Fspan>\n\ncurl --fail --show-error --max-time 20 https:\u002F\u002Fexample.com\u002Fhealthz\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>В конце выполняется отдельный запрос к публичному HTTPS-адресу; здесь тоже нужно заменить \u003Ccode>example.com\u003C\u002Fcode> своим доменом. Если внешний запрос завершился ошибкой, этот пример оставляет выбранный релиз и возвращает ненулевой код. Причину нужно определить отдельно: на доступность домена влияют прокси, DNS и сертификат. Точные условия автоматического отката зависят от инфраструктуры.\u003C\u002Fp>\n\u003Cp>Локальный ответ \u003Ccode>{\"status\":\"ok\"}\u003C\u002Fcode> показывает, что серверный обработчик доступен. Содержимое статей, CSS и клиентские переходы проверяются отдельно при приёмке сайта.\u003C\u002Fp>\n\u003Cp>Перезапуск единственного Node-процесса допускает короткий перерыв в работе сайта. Для обновления без перерыва понадобятся два одновременно работающих экземпляра и переключение трафика между ними.\u003C\u002Fp>\n\u003Cp>После завершения временный worktree удаляется, установленные релизы остаются. Для очистки старых каталогов нужна отдельная политика, сохраняющая проверенную версию для отката.\u003C\u002Fp>\n\u003Ch2 id=\"section-6\">Что подготовить на сервере\u003C\u002Fh2>\n\u003Cp>В этой схеме сборка выполняется обычным пользователем. Повышенные права нужны для перезапуска конкретной службы. В примерах используются пользователь \u003Ccode>publisher\u003C\u002Fcode>, служба \u003Ccode>content-site.service\u003C\u002Fcode> и каталог \u003Ccode>\u002Fsrv\u002Fcontent-site\u003C\u002Fcode>.\u003C\u002Fp>\n\u003Cp>Потребуются Git, Bash, npm, curl, tar, sha256sum и flock. Совместимый Node должен быть доступен в PATH неинтерактивной SSH-сессии; путь \u003Ccode>\u002Fopt\u002Fnode\u002Fbin\u002Fnode\u003C\u002Fcode> в unit указывает на тот же выбранный runtime. Пользователю выкладки нужны права создавать каталоги в \u003Ccode>releases\u003C\u002Fcode> и переключать \u003Ccode>current\u003C\u002Fcode>. Первый рабочий релиз и HTTPS на домене устанавливаются до включения автодеплоя.\u003C\u002Fp>\n\u003Cp>Systemd запускает приложение через \u003Ccode>current\u003C\u002Fcode>. Файл \u003Ccode>\u002Fetc\u002Fsystemd\u002Fsystem\u002Fcontent-site.service\u003C\u002Fcode>:\u003C\u002Fp>\n\u003Cpre tabindex=\"0\" aria-label=\"Блок кода: ini\">\u003Ccode class=\"hljs language-ini\">\u003Cspan class=\"hljs-section\">[Unit]\u003C\u002Fspan>\n\u003Cspan class=\"hljs-attr\">Description\u003C\u002Fspan>=Nuxt content site\n\u003Cspan class=\"hljs-attr\">After\u003C\u002Fspan>=network.target\n\n\u003Cspan class=\"hljs-section\">[Service]\u003C\u002Fspan>\n\u003Cspan class=\"hljs-attr\">User\u003C\u002Fspan>=publisher\n\u003Cspan class=\"hljs-attr\">Group\u003C\u002Fspan>=publisher\n\u003Cspan class=\"hljs-attr\">WorkingDirectory\u003C\u002Fspan>=\u002Fsrv\u002Fcontent-site\u002Fcurrent\n\u003Cspan class=\"hljs-attr\">ExecStart\u003C\u002Fspan>=\u002Fopt\u002Fnode\u002Fbin\u002Fnode \u002Fsrv\u002Fcontent-site\u002Fcurrent\u002Fserver\u002Findex.mjs\n\u003Cspan class=\"hljs-attr\">Environment\u003C\u002Fspan>=HOST=\u003Cspan class=\"hljs-number\">127.0\u003C\u002Fspan>.\u003Cspan class=\"hljs-number\">0.1\u003C\u002Fspan>\n\u003Cspan class=\"hljs-attr\">Environment\u003C\u002Fspan>=PORT=\u003Cspan class=\"hljs-number\">3100\u003C\u002Fspan>\n\u003Cspan class=\"hljs-attr\">Restart\u003C\u002Fspan>=\u003Cspan class=\"hljs-literal\">on\u003C\u002Fspan>-failure\n\u003Cspan class=\"hljs-attr\">RestartSec\u003C\u002Fspan>=\u003Cspan class=\"hljs-number\">5\u003C\u002Fspan>\n\n\u003Cspan class=\"hljs-section\">[Install]\u003C\u002Fspan>\n\u003Cspan class=\"hljs-attr\">WantedBy\u003C\u002Fspan>=multi-user.target\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Администратор разрешает пользователю перезапуск этой службы в отдельном файле sudoers:\u003C\u002Fp>\n\u003Cpre tabindex=\"0\" aria-label=\"Блок кода: sudoers\">\u003Ccode class=\"hljs language-sudoers\">publisher \u003Cspan class=\"hljs-keyword\">ALL\u003C\u002Fspan>=(root) \u003Cspan class=\"hljs-keyword\">NOPASSWD\u003C\u002Fspan>: \u003Cspan class=\"hljs-string\">\u002Fusr\u002Fbin\u002Fsystemctl\u003C\u002Fspan> restart content-site.service\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Правило проверяется командой \u003Ccode>visudo -c\u003C\u002Fcode>, доступ пользователя — \u003Ccode>sudo -n -l \u002Fusr\u002Fbin\u002Fsystemctl restart content-site.service\u003C\u002Fcode>. После установки unit выполняются \u003Ccode>systemctl daemon-reload\u003C\u002Fcode> и \u003Ccode>systemctl enable --now content-site.service\u003C\u002Fcode>.\u003C\u002Fp>\n\u003Cp>Nginx принимает внешний трафик и передаёт запросы приложению на loopback. В HTTPS-блок домена с уже настроенным сертификатом добавляется:\u003C\u002Fp>\n\u003Cpre tabindex=\"0\" aria-label=\"Блок кода: nginx\">\u003Ccode class=\"hljs language-nginx\">\u003Cspan class=\"hljs-section\">location\u003C\u002Fspan> \u002F {\n    \u003Cspan class=\"hljs-attribute\">proxy_pass\u003C\u002Fspan> http:\u002F\u002F127.0.0.1:3100;\n    \u003Cspan class=\"hljs-attribute\">proxy_set_header\u003C\u002Fspan> Host \u003Cspan class=\"hljs-variable\">$host\u003C\u002Fspan>;\n    \u003Cspan class=\"hljs-attribute\">proxy_set_header\u003C\u002Fspan> X-Forwarded-Proto \u003Cspan class=\"hljs-variable\">$scheme\u003C\u002Fspan>;\n    \u003Cspan class=\"hljs-attribute\">proxy_set_header\u003C\u002Fspan> X-Forwarded-For \u003Cspan class=\"hljs-variable\">$proxy_add_x_forwarded_for\u003C\u002Fspan>;\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>На сервере с панелью управления конфигурацию нужно менять предусмотренным панелью способом, чтобы её перегенерация сохранила настройку.\u003C\u002Fp>\n\u003Cp>У SSH здесь два соединения: Actions подключается к серверу, а сервер читает репозиторий в GitHub. \u003Ccode>DEPLOY_HOST_KEYS\u003C\u002Fcode> относится к первому соединению. Для второго серверу отдельно нужны доступ к репозиторию и доверенный ключ GitHub в его \u003Ccode>known_hosts\u003C\u002Fcode>. Ключи серверов проверяются заранее.\u003C\u002Fp>\n\u003Ch2 id=\"section-7\">В уведомлении виден результат конкретного запуска\u003C\u002Fh2>\n\u003Cp>Workflow alexeslm.ru отправляет в Telegram итог: успех, блокировку из-за локальных изменений, пропуск устаревшего коммита или ошибку. В сообщении есть SHA, PR и ссылка на журнал Actions. По ним можно найти изменение и шаг, на котором остановилось обновление. Ошибка отправки сообщения не отменяет установленный релиз.\u003C\u002Fp>\n\u003Cp>Так слияние PR запускает подготовку конкретной версии, а установка получает уже собранный архив. До переключения сайт использует прежний релиз; после него сценарий проверяет запуск и при предусмотренной неудаче возвращает предыдущую версию. Разработчик получает результат операции и журнал для разбора ошибок.\u003C\u002Fp>\n\u003Cp>Примеры написаны специально для кейса. Имена пользователя, службы, домена и каталогов условные; они показывают порядок действий без параметров доступа к серверу сайта.\u003C\u002Fp>\n",[6,9,12,15,18,21,24],{"id":7,"title":8},"section-1","Новая версия собирается, пока работает предыдущая",{"id":10,"title":11},"section-2","Передать серверу именно тот коммит, который приняли",{"id":13,"title":14},"section-3","Собрать релиз вне работающего приложения",{"id":16,"title":17},"section-4","За время сборки мог появиться следующий PR",{"id":19,"title":20},"section-5","Переключить версию и проверить запуск",{"id":22,"title":23},"section-6","Что подготовить на сервере",{"id":25,"title":26},"section-7","В уведомлении виден результат конкретного запуска",1790862055285]