P4. Развёртывание на Helios с контролем качества доставки¶
Схема¶
flowchart LR
push[push в ветку] --> plan
del[удаление ветки] --> plan
disp[workflow_dispatch<br/>rollback] --> plan
plan["plan<br/>slot, URL, id релиза"] --> build
build["build<br/>uv sync --frozen<br/>mkdocs build --strict<br/>секретов нет"] -- артефакт site --> deploy
plan --> deploy
subgraph deploy["deploy · environment helios / helios-preview"]
direction TB
k[ключ + known_hosts] --> up["rsync → ~/ssg/releases/slot/id/"]
up --> act["ssg activate<br/>releases/id → public_html"]
act --> hc{"healthcheck<br/>200 + build-commit"}
hc -- нет, main --> rb[автооткат]
end
| Событие | Что происходит |
|---|---|
push в main |
сборка → релиз <время>-<sha> → ~/public_html/ → healthcheck; при провале — автооткат на предыдущий релиз и ошибка job |
| push в другую ветку | сборка → ~/public_html/preview/<ветка>/ (имя ветки очищено до [A-Za-z0-9._-]), site_url указывает на подкаталог |
| удаление ветки | удаление её preview и релизов |
workflow_dispatch rollback |
возврат на предыдущий релиз (или на указанный id) без пересборки |
push, изменён только README.md |
не запускается |
vars.HELIOS_USER не задан (форк, ещё не настроенный репозиторий) |
весь workflow пропускается, а не падает |
Параллельные выкладки в один слот исключены: concurrency: helios-<slot>, cancel-in-progress: false.
Deploy-ключ и шлюз¶
Отдельная пара ed25519 только для CI (pyengin-ssg-deploy). Публичная часть добавлена в ~/.ssh/authorized_keys
на Helios с ограничениями:
restrict запрещает PTY и все виды проброса. command= подменяет любую запрошенную команду шлюзом
scripts/helios-gate.sh. Шлюз разбирает
$SSH_ORIGINAL_COMMAND и пропускает только:
rsync --server … . ssg/releases/<slot>/<id>/— приём файлов (не отдачу) в каталог релизов, без опций, пишущих в произвольные пути (--log-file,--temp-dirи т. п.);ssg current|activate|rollback|list|remove|check.
Проверка шлюза на попытках злоупотребления (локально, с поддельным $HOME):
SSH_ORIGINAL_COMMAND |
Результат |
|---|---|
| (пусто — интерактивный вход) | отказ |
bash, cat .ssh/authorized_keys |
отказ |
rsync --server --sender … public_html/ (скачать файлы) |
отказ: чтение с сервера |
rsync --server … . public_html/ (писать мимо releases) |
отказ: аргумент |
rsync --server … . ssg/releases/main/../../.ssh/ |
отказ: путь |
rsync --server --log-file=x … |
отказ: опция |
rsync … ssg/releases/main/x; rm -rf ~ |
отказ: спецсимволы |
ssg activate main $(id) |
отказ: спецсимволы |
ssg remove main |
отказ |
ssg check https://evil.com/ … |
отказ: URL |
Итог по T4: украденный ключ позволяет залить и активировать содержимое сайта, но не даёт shell, не даёт читать
другие файлы студента и не позволяет изменить сам шлюз или authorized_keys.
Релизы, preview и откат¶
~/ssg/releases/main/20261012T150102Z-0d340f2/ ← полный снимок сайта
~/ssg/releases/main/20261012T153344Z-9ab1c3e/ ← совпадающие файлы — жёсткие ссылки на предыдущий
~/ssg/state/main/current, previous
~/public_html/ ← копия текущего релиза (кроме preview/)
~/public_html/preview/<ветка>/
Хранятся 5 последних релизов каждого слота (current и previous не удаляются никогда). Откат
(ssg rollback main) копирует предыдущий релиз в public_html за секунды, без пересборки и без CI. Повторный откат
возвращает исходную версию.
Почему не символическая ссылка public_html → releases/<id>: атомарное переключение ln -sfn было бы лучше, но
Apache на Helios отдаёт ~/public_html через UserDir. Поведение с символическими ссылками
(FollowSymLinks/SymLinksIfOwnerMatch) зависит от конфигурации сервера, которую студент не контролирует. Поэтому
активация — rsync --checksum --delete из релиза в public_html внутри сервера.
Обрыв развёртывания на середине¶
| Где оборвалось | Что видит читатель | Восстановление |
|---|---|---|
| сборка или lint | ничего не меняется | исправить и запушить |
загрузка rsync в releases/<id>/ |
ничего не меняется — public_html не тронут, недогруженный релиз не активируется (ssg activate проверяет index.html) |
повторный запуск job; rsync докачает только недостающее |
| активация (копирование внутри Helios) | SIGHUP игнорируется, копирование завершается даже при обрыве SSH. При сбое самого сервера — смесь версий на несколько секунд | ssg rollback main или повторная активация |
| healthcheck не прошёл | в main — автооткат на предыдущий релиз, job красная |
разбор лога |
Тексты пайплайнов¶
.github/workflows/helios.yml
# Публикация на Helios ИТМО (https://se.ifmo.ru/~<user>/) по SSH/rsync с отдельным deploy-ключом.
# main → ~/public_html/ (корень сайта)
# другие ветки → ~/public_html/preview/<ветка>/ (preview-сборки)
# удаление ветки → удаление её preview
# workflow_dispatch: rollback — вернуть предыдущий (или указанный) релиз
#
# Настройка (Settings → Secrets and variables → Actions):
# vars.HELIOS_USER — логин sXXXXXX (не секрет: он и так виден в URL сайта)
# secrets.HELIOS_SSH_KEY — приватный ed25519 deploy-ключ (публичный — в ~/.ssh/authorized_keys на Helios)
# secrets.HELIOS_KNOWN_HOSTS — строки `ssh-keyscan -p 2222 helios.cs.ifmo.ru`, сверенные по отпечатку вручную
name: helios
on:
push:
branches: ["**"]
paths-ignore: ["README.md"]
delete:
workflow_dispatch:
inputs:
action:
description: deploy — пересобрать и выложить; rollback — вернуть релиз
type: choice
options: [deploy, rollback]
default: deploy
release:
description: "rollback: id релиза (пусто — предыдущий)"
required: false
permissions:
contents: read
defaults:
run:
shell: bash # явный bash = -eo pipefail: ошибка rsync не теряется в конвейере с grep
env:
HOST: helios.cs.ifmo.ru
PORT: "2222"
jobs:
# Вычисляем, куда выкладывать. Ветка → безопасное имя каталога.
plan:
runs-on: ubuntu-24.04
if: vars.HELIOS_USER != '' # пока Helios не настроен, workflow просто пропускается
outputs:
slot: ${{ steps.p.outputs.slot }}
url: ${{ steps.p.outputs.url }}
release: ${{ steps.p.outputs.release }}
steps:
- id: p
env:
REF_NAME: ${{ github.event_name == 'delete' && github.event.ref || github.ref_name }}
USER_: ${{ vars.HELIOS_USER }}
run: |
# имя ветки пришло от пользователя — обрабатываем как данные, а не как код
safe=$(printf '%s' "$REF_NAME" | tr -c 'A-Za-z0-9._-' '-' | cut -c1-40)
if [ "$REF_NAME" = main ]; then slot=main; path=""; else slot="preview-$safe"; path="preview/$safe/"; fi
echo "slot=$slot" >> "$GITHUB_OUTPUT"
echo "url=https://se.ifmo.ru/~$USER_/$path" >> "$GITHUB_OUTPUT"
echo "release=$(date -u +%Y%m%dT%H%M%SZ)-${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
build:
needs: plan
if: github.event_name == 'push' || inputs.action == 'deploy'
runs-on: ubuntu-24.04
env:
SITE_URL: ${{ needs.plan.outputs.url }}
DEPLOY_TARGET: helios
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
with:
enable-cache: true
cache-dependency-glob: uv.lock
- run: uv sync --frozen
- name: Lint — строгая сборка
run: uv run mkdocs build --strict
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: site
path: site
retention-days: 7
deploy:
needs: [plan, build]
if: always() && needs.plan.result == 'success' && needs.build.result != 'failure' && needs.build.result != 'cancelled'
runs-on: ubuntu-24.04
timeout-minutes: 10
concurrency: # не допускаем двух одновременных выкладок в один слот
group: helios-${{ needs.plan.outputs.slot }}
cancel-in-progress: false
environment:
name: ${{ needs.plan.outputs.slot == 'main' && 'helios' || 'helios-preview' }}
url: ${{ needs.plan.outputs.url }}
env:
SLOT: ${{ needs.plan.outputs.slot }}
URL: ${{ needs.plan.outputs.url }}
RELEASE: ${{ needs.plan.outputs.release }}
TARGET: ${{ vars.HELIOS_USER }}@helios.cs.ifmo.ru
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: {sparse-checkout: scripts}
- name: SSH — ключ и known_hosts (проверка ключа хоста НЕ отключается)
env:
KEY: ${{ secrets.HELIOS_SSH_KEY }}
KNOWN_HOSTS: ${{ secrets.HELIOS_KNOWN_HOSTS }}
run: |
install -d -m 700 ~/.ssh
printf '%s\n' "$KEY" > ~/.ssh/deploy && chmod 600 ~/.ssh/deploy
printf '%s\n' "$KNOWN_HOSTS" > ~/.ssh/known_hosts
cat > ~/.ssh/config <<CFG
Host helios.cs.ifmo.ru
Port $PORT
IdentityFile ~/.ssh/deploy
IdentitiesOnly yes
StrictHostKeyChecking yes
ServerAliveInterval 15
ConnectTimeout 20
CFG
# ключ привязан к ~/ssg/bin/ssg-gate.sh (command= в authorized_keys): shell недоступен,
# только rsync в ~/ssg/releases/ и подкоманды `ssg ...`
echo "текущий релиз $SLOT: $(ssh "$TARGET" ssg current "$SLOT")"
- name: Удалить preview удалённой ветки
if: github.event_name == 'delete'
run: ssh "$TARGET" ssg remove "$SLOT"
- name: Откат
if: github.event_name == 'workflow_dispatch' && inputs.action == 'rollback'
env: { REL: "${{ inputs.release }}" }
run: |
ssh "$TARGET" ssg rollback "$SLOT" $REL
ssh "$TARGET" ssg list "$SLOT"
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
if: needs.build.result == 'success'
with: { name: site, path: site }
- name: Загрузка релиза (rsync; совпадающие по содержимому файлы — жёсткие ссылки на текущий релиз)
if: needs.build.result == 'success'
run: |
start=$(date +%s)
cur=$(ssh "$TARGET" ssg current "$SLOT")
# --checksum: сравнение по содержимому. По умолчанию rsync сравнивает размер+mtime,
# и --link-dest связывал разные файлы одинаковой длины (см. «Отладка»)
rsync -rlptzc --delete ${cur:+--link-dest=../$cur} site/ "$TARGET:ssg/releases/$SLOT/$RELEASE/"
echo "UPLOAD_S=$(( $(date +%s) - start ))" >> "$GITHUB_ENV"
- name: Активация релиза
id: activate
if: needs.build.result == 'success'
run: |
start=$(date +%s)
ssh "$TARGET" ssg activate "$SLOT" "$RELEASE"
{
echo "### Helios: $SLOT → $URL"
echo "| релиз | загрузка, с | активация, с |"
echo "|---|---:|---:|"
echo "| \`$RELEASE\` | $UPLOAD_S | $(( $(date +%s) - start )) |"
} >> "$GITHUB_STEP_SUMMARY"
- name: Healthcheck — HTTP 200 и build-commit
if: needs.build.result == 'success'
run: |
set +e
./scripts/healthcheck.sh "$URL" "$GITHUB_SHA" 5; rc=$?
if [ $rc = 2 ]; then
# se.ifmo.ru может не отвечать зарубежным адресам — тогда проверка выполняется с самого Helios
echo "::warning::se.ifmo.ru недоступен с раннера, проверка с Helios"
ssh "$TARGET" ssg check "$URL" "$GITHUB_SHA"; rc=$?
fi
exit $rc
- name: Автооткат main при провале проверки
# только если новый релиз уже был активирован, иначе «откат» снял бы рабочую версию
if: failure() && env.SLOT == 'main' && steps.activate.outcome == 'success'
run: ssh "$TARGET" ssg rollback main
.github/workflows/pages.yml
# Сборка и публикация на GitHub Pages официальной связкой upload-pages-artifact + deploy-pages.
# Pages → Source = GitHub Actions. Сторонние actions зафиксированы по SHA коммита (см. T4).
name: pages
on:
push:
branches: ["**"] # любая ветка: собираем; публикуем только main
pull_request: # PR из форка: без секретов и без деплоя, только сборка
workflow_dispatch:
permissions:
contents: read # минимум по умолчанию; права на Pages выдаются только job deploy
concurrency:
group: pages-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-24.04
env:
SITE_URL: https://bailaa.github.io/pyengin-ssg/
DEPLOY_TARGET: github-pages
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
with:
enable-cache: true # кэш uv по хешу uv.lock
cache-dependency-glob: uv.lock
- name: Install (frozen lock, Python из .python-version)
run: uv sync --frozen
- name: Lint — строгая сборка (битые ссылки и предупреждения = ошибка)
run: uv run mkdocs build --strict
- uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
with:
path: site
deploy:
needs: build
if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
runs-on: ubuntu-24.04
permissions:
pages: write # публиковать в Pages
id-token: write # OIDC-токен, которым deploy-pages подтверждает происхождение артефакта
environment:
name: github-pages
url: ${{ steps.deploy.outputs.page_url }}
steps:
- id: deploy
uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: {sparse-checkout: scripts}
- name: Healthcheck — код 200 и хеш коммита в HTML
run: ./scripts/healthcheck.sh "${{ steps.deploy.outputs.page_url }}" "$GITHUB_SHA"
scripts/healthcheck.sh
#!/usr/bin/env bash
# Проверка опубликованной сборки: HTTP 200 и <meta name="build-commit" content="$SHA"> в HTML.
# Использование: healthcheck.sh URL SHA [попыток=10]
# Код выхода: 0 — ок; 1 — ответ есть, но не тот (код/контрольная строка); 2 — сайт недоступен по сети.
set -uo pipefail
url=$1 sha=$2 tries=${3:-10}
body=$(mktemp)
for i in $(seq 1 "$tries"); do
code=$(curl -sS -m 20 -o "$body" -w '%{http_code}' "$url" 2>/dev/null) || code=000
if [[ $code == 200 ]] && grep -q "name=\"build-commit\" content=\"$sha\"" "$body"; then
echo "OK: $url → 200, build-commit=$sha (попытка $i)"
exit 0
fi
echo "попытка $i: HTTP $code, build-commit=$(grep -o 'build-commit" content="[0-9a-f]*' "$body" | cut -d'"' -f3 || true)"
sleep 6 # CDN Pages и кэш Apache могут отдавать прежнюю версию несколько секунд
done
[[ $code == 000 ]] && { echo "::error::$url недоступен по сети"; exit 2; }
echo "::error::$url: ожидался HTTP 200 и build-commit=$sha"
exit 1
Измерения и сравнение с GitHub Pages¶
Адрес на Helios: https://se.ifmo.ru/~s566056/. Все числа взяты из реальных запусков Actions
(длительность job и шагов из API GitHub), раннер ubuntu-24.04.
Время доставки¶
| Метрика | GitHub Pages (pages.yml) |
Helios (helios.yml) |
|---|---|---|
| Запусков в выборке | 4 успешных на main |
3 успешных (2 на main, 1 preview) |
| plan (вычисление слота, URL) | — | 3 с |
build: установка + mkdocs build --strict |
14–19 с (lint 4–7 с) | 19–21 с (lint 4–6 с) |
| Загрузка | артефакт в Pages | rsync в releases/ — 6–7 с |
| Публикация | deploy-pages 5–7 с |
ssg activate — 2–3 с |
| Healthcheck | 0–1 с | 1–2 с |
| deploy, всего | 10–11 с | 17–20 с |
| push → сайт обновлён | ≈ 26–30 с | ≈ 40–44 с |
| Откат на предыдущий релиз | повторный запуск старого workflow (≈ 30 с) | workflow_dispatch: rollback — job целиком 17 с, сама операция 2–3 с, без пересборки |
Выборки маленькие (3–4 запуска), поэтому это порядок величин, а не статистика. Один из запусков Pages дал выброс:
шаг deploy-pages занял 73 с вместо 5–7 (job deploy — 78 с). У Helios такого разброса в выборке не было,
но у него и меньше звеньев вне нашего контроля.
Сравнение площадок¶
| Критерий | GitHub Pages | Helios |
|---|---|---|
| Время доставки | быстрее (≈ 28 с против ≈ 42 с), но с редкими выбросами | ≈ 42 с, 6–7 с из них — rsync |
| Надёжность | управляемая платформа, CDN, атомарная публикация артефакта | один сервер университета; публикация копированием, не атомарна (см. ниже) |
| Доступность | с раннеров и с Mac отвечает (200) | с Mac через VPN в Нидерландах не отвечал, с раннеров GitHub отвечает — доступность зависит от сети клиента |
| Удобство отладки | весь процесс выкладки скрыт внутри deploy-pages; на выброс 73 с повлиять нельзя |
свой bash: видно каждую команду rsync/ssh и отказы шлюза (ssg-gate: запрещено: опция --stats) |
| Preview веток | нужен отдельный механизм | из коробки: preview/<ветка>/ |
| Откат | перезапуск старого деплоя | один шаг, 2–3 с, предыдущие 5 релизов на диске |
| Права при компрометации | токен только на Pages | ключ ограничен шлюзом, но это полный контроль над содержимым ~/public_html |
Скриншоты запусков¶






Проверенные сценарии на Helios¶
| Сценарий | Как проверено | Результат |
|---|---|---|
Деплой main в корень |
запуск 38265777276 | https://se.ifmo.ru/~s566056/ → 200, build-commit = SHA коммита |
| Preview ветки | ветка demo/preview |
https://se.ifmo.ru/~s566056/preview/demo-preview/ → 200, корень не затронут |
| Откат | workflow_dispatch rollback, затем внешняя проверка |
build-commit сменился с d0cdccf на 1d32973, повторный откат вернул 25a29d5 |
| Healthcheck при несовпадении | helios-probe с SHA из нулей |
3 попытки по 6 с, затем ##[error] … ожидался HTTP 200 и build-commit=000…, job красная (код 1) |
| Автооткат при провале проверки | не воспроизведён на реальном сервере: провалить healthcheck после активации, не ломая main, нельзя |
логика проверена чтением; ручной откат проверен отдельно |
Побочное наблюдение
Любой push в main, включая правку workflow-файла диагностики, запускает деплой и создаёт новый релиз.
Поэтому «предыдущий» релиз для отката — это предыдущий по времени активации, а не по смыслу.
Для точного возврата на нужную версию используется rollback с параметром release.