Приложение А. Тексты пайплайнов
Листинги подключаются прямо из файлов репозитория при сборке сайта, поэтому не расходятся с реальными файлами. Комментарии написаны в самих файлах.
А.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"
|