Перейти к содержанию

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,command="/home/studs/sXXXXXX/ssg/bin/ssg-gate.sh" ssh-ed25519 AAAA… pyengin-ssg-deploy

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

helios preview

helios rollback

probe failure

pages success

pages failure

Проверенные сценарии на 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.

сборка 964f3e7 · 2026-10-12 15:01 UTC · данные 749742082a6a · github-pages