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

Приложение А. Тексты пайплайнов

Листинги подключаются прямо из файлов репозитория при сборке сайта, поэтому не расходятся с реальными файлами. Комментарии написаны в самих файлах.

А.1. Workflow сайта (.github/workflows/deploy.yml)

name: Build and deploy docs

# События, запускающие пайплайн:
#  - push в main            -> lint -> build -> deploy (Pages и Cloudflare)
#  - pull_request в main    -> lint -> build (деплой пропускается, секреты форков недоступны)
#  - workflow_dispatch      -> ручной запуск (деплой только если выбрана ветка main)
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
  workflow_dispatch:

# минимальные права по умолчанию; расширяются точечно в нужных заданиях
permissions:
  contents: read

concurrency:
  group: docs-${{ github.ref }}
  cancel-in-progress: true

env:
  PYTHON_VERSION: "3.11"
  PAGES_URL: https://777werona-afk.github.io/vr-gaze-site/

jobs:
  # ---------------------------------------------------------------- 1. lint
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-python@v5
        with:
          python-version: ${{ env.PYTHON_VERSION }}
          cache: pip            # кэш зависимостей по хешу requirements.txt

      - name: Install dependencies
        run: pip install -r requirements.txt

      - name: Compute results (data -> tables and figures)
        run: python scripts/analyze.py

      # --strict превращает битые ссылки и предупреждения в ошибку сборки
      - name: Build in strict mode
        run: mkdocs build --strict --site-dir /tmp/site-lint

  # ---------------------------------------------------------------- 2. build
  build:
    needs: lint
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-python@v5
        with:
          python-version: ${{ env.PYTHON_VERSION }}
          cache: pip

      - run: pip install -r requirements.txt
      - run: python scripts/analyze.py

      # сборка для GitHub Pages
      - name: Build for GitHub Pages
        run: mkdocs build --strict --site-dir site
        env:
          SITE_URL: ${{ env.PAGES_URL }}

      # отдельная сборка для Cloudflare (свой базовый URL)
      - name: Build for Cloudflare Pages
        run: mkdocs build --strict --site-dir site-hosting
        env:
          SITE_URL: ${{ vars.HOSTING_SITE_URL || env.PAGES_URL }}

      - uses: actions/upload-pages-artifact@v3
        with:
          path: site

      - uses: actions/upload-artifact@v4
        with:
          name: site-hosting
          path: site-hosting
          retention-days: 7

  # ---------------------------------------------------------------- 3a. deploy: GitHub Pages
  # только с основной ветки; для остальных веток выполняется лишь сборка
  deploy-pages:
    needs: build
    if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
    runs-on: ubuntu-latest
    permissions:
      pages: write
      id-token: write
    environment:
      name: github-pages
      url: ${{ steps.deployment.outputs.page_url }}
    steps:
      - id: deployment
        uses: actions/deploy-pages@v4

      - name: Healthcheck (HTTP 200 + control string)
        run: |
          code=$(curl -s -L -o page.html -w '%{http_code}' "${{ steps.deployment.outputs.page_url }}")
          echo "HTTP $code"
          test "$code" = "200"
          grep -q 'Стабилизация взгляда в VR' page.html

  # ---------------------------------------------------------------- 3b. deploy: Cloudflare Pages
  # Нужны секреты репозитория CLOUDFLARE_API_TOKEN и CLOUDFLARE_ACCOUNT_ID и переменная
  # HOSTING_SITE_URL (адрес вида https://vr-gaze-site.pages.dev). Без секретов задание пропускается.
  deploy-cloudflare:
    needs: build
    if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
    runs-on: ubuntu-latest
    env:
      HAS_SECRETS: ${{ secrets.CLOUDFLARE_API_TOKEN != '' }}
      CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
      CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
      PROJECT: vr-gaze-site
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: site-hosting
          path: site-hosting

      # проект создаётся один раз; если он уже есть, команда завершается ошибкой, и это нормально
      - name: Create Pages project (once)
        if: env.HAS_SECRETS == 'true'
        run: npx --yes wrangler@4 pages project create "$PROJECT" --production-branch main || true

      - name: Deploy to Cloudflare Pages
        if: env.HAS_SECRETS == 'true'
        run: npx --yes wrangler@4 pages deploy site-hosting --project-name "$PROJECT" --branch main

      - name: Healthcheck (HTTP 200 + control string)
        if: env.HAS_SECRETS == 'true' && vars.HOSTING_SITE_URL != ''
        run: |
          sleep 10
          code=$(curl -s -L -o page.html -w '%{http_code}' "${{ vars.HOSTING_SITE_URL }}")
          echo "HTTP $code"
          test "$code" = "200"
          grep -q 'Стабилизация взгляда в VR' page.html

      - name: Skipped (no Cloudflare secrets)
        if: env.HAS_SECRETS != 'true'
        run: echo "Секреты Cloudflare не заданы — выкладка пропущена."

  # ---------------------------------------------------------------- 3c. deploy: по SSH собственным action
  # Используется action https://github.com/777werona-afk/rsync-ssh-deploy (тег v1).
  # Постоянного SSH-сервера у проекта нет, поэтому job выкладывает сайт на sshd, поднятый на самом раннере,
  # и проверяет результат. Для реального сервера достаточно заменить host, user, path и ключ на секреты.
  deploy-ssh:
    needs: build
    if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: site-hosting
          path: site-hosting

      - name: Start local sshd and create a key
        id: ssh
        run: |
          sudo apt-get update -qq && sudo apt-get install -y -qq openssh-server rsync
          sudo service ssh start
          ssh-keygen -t ed25519 -N '' -f "$RUNNER_TEMP/id_site" -q
          mkdir -p ~/.ssh && chmod 700 ~/.ssh
          cat "$RUNNER_TEMP/id_site.pub" >> ~/.ssh/authorized_keys
          chmod 600 ~/.ssh/authorized_keys
          echo "user=$(whoami)" >> "$GITHUB_OUTPUT"
          {
            echo 'key<<EOF_KEY'
            cat "$RUNNER_TEMP/id_site"
            echo 'EOF_KEY'
          } >> "$GITHUB_OUTPUT"

      - name: Deploy over SSH with rsync-ssh-deploy
        id: deploy
        uses: 777werona-afk/rsync-ssh-deploy@v1
        with:
          host: localhost
          user: ${{ steps.ssh.outputs.user }}
          path: /tmp/site-ssh
          source: site-hosting
          key: ${{ steps.ssh.outputs.key }}
          delete: 'true'

      - name: Check the deployed copy
        run: |
          echo "Передано файлов: ${{ steps.deploy.outputs.files_transferred }}"
          test -f /tmp/site-ssh/index.html
          grep -q 'Стабилизация взгляда в VR' /tmp/site-ssh/index.html

А.2. Описание action (action.yml)

Файл из репозитория rsync-ssh-deploy, версия v1.0.0.

name: 'Rsync SSH Deploy'
description: 'Выкладывает каталог (например, собранный статический сайт) на сервер по SSH с помощью rsync; поддерживает удаление лишних файлов.'
author: 'Marina Kozhevyatkina'

branding:
  icon: 'upload-cloud'
  color: 'blue'

inputs:
  host:
    description: 'Имя или адрес сервера, например helios.example.org.'
    required: true
  user:
    description: 'Имя пользователя на сервере.'
    required: true
  path:
    description: 'Каталог назначения на сервере (абсолютный или относительно домашнего каталога).'
    required: true
  source:
    description: 'Исходный каталог в рабочей папке задания.'
    required: false
    default: 'site'
  key:
    description: 'Закрытый SSH-ключ (передавайте через secrets, не в открытом виде).'
    required: true
  delete:
    description: 'Если "true", на сервере удаляются файлы, которых нет в исходном каталоге (флаг rsync --delete).'
    required: false
    default: 'false'
  port:
    description: 'Порт SSH.'
    required: false
    default: '22'
  known_hosts:
    description: 'Строки known_hosts для проверки сервера. Если пусто, отпечаток запрашивается через ssh-keyscan при первом подключении (менее безопасно).'
    required: false
    default: ''
  dry_run:
    description: 'Если "true", rsync только показывает, что будет сделано (--dry-run).'
    required: false
    default: 'false'

outputs:
  destination:
    description: 'Итоговое назначение в формате user@host:path.'
    value: ${{ steps.sync.outputs.destination }}
  files_transferred:
    description: 'Сколько файлов передано (по статистике rsync).'
    value: ${{ steps.sync.outputs.files_transferred }}
  files_deleted:
    description: 'Сколько файлов удалено на сервере (по статистике rsync; 0, если delete не включён).'
    value: ${{ steps.sync.outputs.files_deleted }}

runs:
  using: 'composite'
  steps:
    - name: Check inputs
      shell: bash
      env:
        IN_HOST: ${{ inputs.host }}
        IN_USER: ${{ inputs.user }}
        IN_PATH: ${{ inputs.path }}
        IN_SOURCE: ${{ inputs.source }}
        IN_DELETE: ${{ inputs.delete }}
      run: |
        # входные данные проверяются до подключения к серверу
        for v in IN_HOST IN_USER IN_PATH IN_SOURCE; do
          if [ -z "${!v}" ]; then echo "::error::Параметр ${v#IN_} не задан"; exit 1; fi
        done
        if [ ! -d "$IN_SOURCE" ]; then
          echo "::error::Исходный каталог '$IN_SOURCE' не найден"; exit 1
        fi
        if [ -z "$(ls -A "$IN_SOURCE")" ]; then
          echo "::error::Исходный каталог '$IN_SOURCE' пуст: выкладка с удалением стёрла бы сайт на сервере"; exit 1
        fi
        # защита от опасных путей при удалении
        if [ "$IN_DELETE" = "true" ]; then
          case "$IN_PATH" in
            /|.|~|~/|"") echo "::error::Путь '$IN_PATH' слишком опасен для режима delete"; exit 1;;
          esac
        fi

    - name: Prepare SSH key
      shell: bash
      env:
        IN_KEY: ${{ inputs.key }}
        IN_HOST: ${{ inputs.host }}
        IN_PORT: ${{ inputs.port }}
        IN_KNOWN_HOSTS: ${{ inputs.known_hosts }}
      run: |
        # ключ и known_hosts лежат во временной папке раннера и удаляются в последнем шаге
        dir="$RUNNER_TEMP/rsync-ssh-deploy"
        mkdir -p "$dir" && chmod 700 "$dir"
        printf '%s\n' "$IN_KEY" > "$dir/key"
        chmod 600 "$dir/key"
        if [ -n "$IN_KNOWN_HOSTS" ]; then
          printf '%s\n' "$IN_KNOWN_HOSTS" > "$dir/known_hosts"
        else
          echo "::warning::known_hosts не задан: отпечаток сервера берётся через ssh-keyscan и не проверяется заранее"
          ssh-keyscan -p "$IN_PORT" "$IN_HOST" > "$dir/known_hosts" 2>/dev/null
        fi
        echo "RSD_DIR=$dir" >> "$GITHUB_ENV"

    - name: Sync with rsync
      id: sync
      shell: bash
      env:
        IN_HOST: ${{ inputs.host }}
        IN_USER: ${{ inputs.user }}
        IN_PATH: ${{ inputs.path }}
        IN_SOURCE: ${{ inputs.source }}
        IN_DELETE: ${{ inputs.delete }}
        IN_PORT: ${{ inputs.port }}
        IN_DRY: ${{ inputs.dry_run }}
      run: |
        args=(-az --stats)
        [ "$IN_DELETE" = "true" ] && args+=(--delete)
        [ "$IN_DRY" = "true" ] && args+=(--dry-run)
        ssh_cmd="ssh -i $RSD_DIR/key -p $IN_PORT -o IdentitiesOnly=yes -o UserKnownHostsFile=$RSD_DIR/known_hosts -o StrictHostKeyChecking=yes"
        dest="$IN_USER@$IN_HOST:$IN_PATH"
        # слэш после источника: копируется содержимое каталога, а не сам каталог
        rsync "${args[@]}" -e "$ssh_cmd" "${IN_SOURCE%/}/" "${dest%/}/" | tee "$RSD_DIR/stats.txt"
        num() { grep -E "$1" "$RSD_DIR/stats.txt" | head -n1 | awk -F: '{gsub(/[ ,]/,"",$2); print $2}'; }
        {
          echo "destination=$dest"
          echo "files_transferred=$(num 'Number of (regular )?files transferred')"
          echo "files_deleted=$(num 'Number of deleted files')"
        } >> "$GITHUB_OUTPUT"

    - name: Remove key
      if: always()
      shell: bash
      run: |
        # ключ не остаётся на раннере даже при ошибке rsync
        [ -n "${RSD_DIR:-}" ] && rm -rf "$RSD_DIR"
        true

А.3. Тестовый workflow action (.github/workflows/test.yml)

name: Test action

# Проверка action на самом раннере: поднимаем локальный sshd и выкладываем каталог на localhost.
on:
  push:
    branches: [main]
  pull_request:
  workflow_dispatch:

permissions:
  contents: read

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Start local sshd and create a key
        id: ssh
        run: |
          sudo apt-get update -qq && sudo apt-get install -y -qq openssh-server rsync
          sudo service ssh start
          ssh-keygen -t ed25519 -N '' -f "$RUNNER_TEMP/id_test" -q
          mkdir -p ~/.ssh && chmod 700 ~/.ssh
          cat "$RUNNER_TEMP/id_test.pub" >> ~/.ssh/authorized_keys
          chmod 600 ~/.ssh/authorized_keys
          echo "user=$(whoami)" >> "$GITHUB_OUTPUT"
          {
            echo 'key<<EOF_KEY'
            cat "$RUNNER_TEMP/id_test"
            echo 'EOF_KEY'
          } >> "$GITHUB_OUTPUT"

      - name: Prepare source directory
        run: |
          mkdir -p src/sub
          echo "one" > src/index.html
          echo "two" > src/sub/page.html

      - name: First deploy
        id: first
        uses: ./
        with:
          host: localhost
          user: ${{ steps.ssh.outputs.user }}
          path: /tmp/deploy-target
          source: src
          key: ${{ steps.ssh.outputs.key }}
          delete: 'true'

      - name: Create a stale file on the server and remove a file from the source
        run: |
          echo "stale" > /tmp/deploy-target/stale.html
          rm src/sub/page.html

      - name: Second deploy (must delete stale files)
        id: second
        uses: ./
        with:
          host: localhost
          user: ${{ steps.ssh.outputs.user }}
          path: /tmp/deploy-target
          source: src
          key: ${{ steps.ssh.outputs.key }}
          delete: 'true'

      - name: Check result and outputs
        run: |
          echo "first: ${{ steps.first.outputs.files_transferred }} transferred"
          echo "second: ${{ steps.second.outputs.files_deleted }} deleted"
          test -f /tmp/deploy-target/index.html
          test ! -e /tmp/deploy-target/stale.html
          test ! -e /tmp/deploy-target/sub/page.html
          test "${{ steps.second.outputs.destination }}" = "${{ steps.ssh.outputs.user }}@localhost:/tmp/deploy-target"

      - name: Create an empty source directory
        run: mkdir empty

      - name: Empty source must be rejected
        id: empty
        continue-on-error: true
        uses: ./
        with:
          host: localhost
          user: ${{ steps.ssh.outputs.user }}
          path: /tmp/deploy-target
          source: empty
          key: ${{ steps.ssh.outputs.key }}
          delete: 'true'

      - name: Check that the empty source failed
        run: test "${{ steps.empty.outcome }}" = "failure"

А.4. Перестановка мажорного тега (.github/workflows/release.yml)

name: Move major tag

# При публикации тега vX.Y.Z подвижный тег vX переставляется на тот же коммит,
# чтобы пользователи могли писать uses: owner/repo@v1 и получать исправления.
on:
  push:
    tags:
      - 'v[0-9]+.[0-9]+.[0-9]+'

permissions:
  contents: write

jobs:
  major-tag:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Move vX tag
        run: |
          major="${GITHUB_REF_NAME%%.*}"
          git tag -f "$major" "$GITHUB_SHA"
          git push -f origin "$major"