Недавно мне потребовалось собрать и развернуть документацию для одного из своих небольших проектов на Python. Написал документацию, собрал Sphinx'ом, дальше собрался заливать на readthedocs.org и обнаружил что без VPN сайт не алё. Более того, почему то и с VPN нормально не получалось импортировать свой проект с GitHub.
Не долго думая, решил изучить ситуацию на "рынке" и нашел неплохую альтернативу - GitHub Pages. Эта статья о том, как я деплоил мультиверсионную документацию на GitHub Pages c помощью GitHub Actions (предполагается, что вы хотя бы немного знакомы с данной фичей) и своими собственными "костылями".
Пишем стартовый workflow
Для примера, рассмотрим проект со следующей общей структурой:
.github/
workflows/
...
docs/
requirements.txt
source/
...
...
src/
...
LICENSE
README.md
...
В директории docs/source
лежат конфигурационные файлы для сборки будущего сайта Sphinx'ом, в docs/requirements.txt
соответственно зависимости (sphinx, ...). Вообще говоря, Sphinx в данной статье не по существу, то есть вы можете аналогично использовать другие библиотеки для сборки доков (да и проект может быть вообще не на Python).
Для деплоя документации, главным образом, будем использовать peaceiris/actions-gh-pages. Данный экшон в выбраную ветку (по дефолту это gh-pages
) будет заливать собранный сайт с доками. Конечно, можно сливать все в папку docs
ветки main
, но по-моему отделять документацию от остального проекта куда практичнее.
Напишем теперь наш стартовый workflow для деплоя доков. Создаем файлик docs.yaml
в папке .github/workflows
:
стартовый docs.yaml
name: Docs
on:
push:
branches: [ main ]
tags:
- 'v*.*.*'
permissions:
contents: write
jobs:
docs-gen:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 1
- uses: actions/setup-python@v3
with:
python-version: 3.7
- name: Install dependencies
run: |
pip install -r docs/requirements.txt
- name: Sphinx build
run: |
sphinx-build docs/source docs/_build
- name: Deploy docs
uses: peaceiris/actions-gh-pages@v3
with:
allow_empty_commit: true
personal_token: ${{ secrets.DEPLOY_TOKEN }}
publish_branch: gh-pages
publish_dir: docs/_build/
Итак, что здесь происходит? Рассмотрим по шагам. Во-первых, выбираем тригер для запуска нашего workflow:
on:
push:
branches: [ main ]
tags:
- 'v*.*.*'
Я выбрал вариант c push'ем тага в главную ветку main, что в принципе логично, так деплой документации будет происходить при тагировании новой версии нашего проекта. Как вариант можно сделать это вообще при срезе релиза
on:
release:
types: [ created ]
или при самостоятельном запуске в любой момент
on:
workflow_dispatch:
inputs:
...
В блоке permissions
обязательно открываем права на запись. Наконец в нашей джобе docs-gen
ставим зависимости из docs/requirements.txt
и запускаем sphinx:
- name: Sphinx build
run: |
sphinx-build docs/source docs/_build
который собирает доки в папочку docs/_build
.
Подключаем экшон actions-gh-pages
- name: Deploy docs
uses: peaceiris/actions-gh-pages@v3
with:
allow_empty_commit: true
personal_token: ${{ secrets.DEPLOY_TOKEN }}
publish_branch: gh-pages
publish_dir: docs/_build/
Данный экшон работает несложно, воспользовавшись стартовым мануалом в большинстве случаев можно достаточно быстро получить желаемый результат. Однако, есть нюанс с которым я столкнулся. Это personal_token
. При использовании стандартного GITHUB_TOKEN
, то есть
github_token: ${{ secrets.GITHUB_TOKEN }}
смело может вылететь ошибка типа этой:
remote: error: GH006: Protected branch update failed for refs/heads/master.
remote: error: Cannot force-push to this protected branch
В моем случае у меня была установлена защита на ветки, и GITHUB_TOKEN'у не хватало прав для force push'а. Поэтому создаем PAT (Personal Access Token) с нужными правами, добавляем его в секреты (secrets.DEPLOY_TOKEN
) репозитория и используем.
В остальном каких-то сильных проблем не было.
Включаем GitHub Pages
Идем в settings нашего репозитория и в разделе Code and automation жмем на Pages. В подразделе Source раздела Build and deployment выбираю Deploy from a branch
, а в подразделе Branch выбираю ветку gh-pages
и место /root
откуда будут грузиться доки. Теперь документация будет грузиться из корня gh-pages.
В принципе на этом и все с настройкой GitHub Pages. Теперь при успешном выполнении нашего workflow Docs
, после будет автоматом запущен GitHub Pages'овский workflow pages-build-deployment
и станет активным построенный сайт. Перейдя по ссылке типа https://username.github.io/reponame/ сможем увидеть результат.
Добавляем поддержку мультиверсионной документации
Все бы хорошо, но фактически мы сделали только то, что у нас при каждом новом деплое будет перезаписываться все содержимое gh-pages и по итогу на сайте мы будем видеть только документацию для актуальной версии проекта, в то время как для остальных версий все будет стерто. Конечно нас это не устраивает.
Дополним наш workflow.
прокаченный docs.yaml
name: Docs
on:
push:
branches: [ main ]
tags:
- 'v*.*.*'
permissions:
contents: write
jobs:
docs-gen:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 1
- uses: actions/setup-python@v3
with:
python-version: 3.7
- name: Install dependencies
run: |
pip install -r docs/requirements.txt
- name: Sphinx build
run: |
sphinx-build docs/source docs/_build
- name: Deploy docs
uses: peaceiris/actions-gh-pages@v3
with:
allow_empty_commit: true
destination_dir: ./${{ github.ref_name }}
force_orphan: false
keep_files: true
personal_token: ${{ secrets.DEPLOY_TOKEN }}
publish_branch: gh-pages
publish_dir: docs/_build/
- name: Change redirect
uses: jannekem/run-python-script-action@v1
id: script
with:
script: |
import re
path, pattern = "docs/redirect/index.html", re.compile(r"\{\s*%\s*latest-version\s*%\s*}")
with open(path, "r", encoding="utf8") as file:
content = pattern.sub("${{ github.ref_name }}", file.read())
with open(path, "w+", encoding="utf8") as file:
file.write(content)
- name: Deploy redirect
uses: peaceiris/actions-gh-pages@v3
with:
allow_empty_commit: true
force_orphan: false
keep_files: true
personal_token: ${{ secrets.DEPLOY_TOKEN }}
publish_branch: gh-pages
publish_dir: docs/redirect/
Ставим
destination_dir: ./${{ github.ref_name }}
что бы документация грузилась не в корень ветки gh-pages, а в папку с именем соответствующего тага. В итоге выглядеть это будет как-то так:
v0.1.0/
...
v0.2.0/
...
...
Используем
keep_files: true
для отмены перезаписи существующих файлов в ветке gh-pages. Однако как указано в документации, actions-gh-pages версия 3 не поддерживает работу с параметром force_orphan
, поэтому
force_orphan: false
Итак, теперь для каждого тага будет создаваться документация и помещаться не в корень gh-pages, а в отдельную папку с именем этого тага. Но GitHub Pages грузит сайт с корня ветки. Значит настраиваем редирект. На самом деле здесь можно поступить по-разному. Например, можно в ветке gh-pages дублировать актуальную версию документации в папку с названием main
и поместить в корень ветки index.html
со следующим содержимым (взято от сюда):
<!DOCTYPE html>
<html>
<head>
<title>Redirecting to latest version/</title>
<meta charset="utf-8">
<meta content="0; URL=https://username.github.io/reponame/main/index.html" http-equiv="refresh">
<link href="https://username.github.io/reponame/main/index.html" rel="canonical">
</head>
</html>
Я же пошел немного другим путем. В папочку docs нашего репозитория добавляем redirect/index.html
вида:
<!DOCTYPE html>
<html>
<head>
<title>Redirecting to latest-version/</title>
<meta charset="utf-8">
<meta content="0; URL=https://username.github.io/reponame/{% latest-version %}/index.html" http-equiv="refresh">
<link href="https://username.github.io/reponame/{% latest-version %}/index.html" rel="canonical">
</head>
</html>
а в джобу добавляю 2 шага - Change redirect
и Deploy redirect
- name: Change redirect
uses: jannekem/run-python-script-action@v1
id: script
with:
script: |
import re
path, pattern = "docs/redirect/index.html", re.compile(r"\{\s*%\s*latest-version\s*%\s*}")
with open(path, "r", encoding="utf8") as file:
content = pattern.sub("${{ github.ref_name }}", file.read())
with open(path, "w+", encoding="utf8") as file:
file.write(content)
- name: Deploy redirect
uses: peaceiris/actions-gh-pages@v3
with:
allow_empty_commit: true
force_orphan: false
keep_files: true
personal_token: ${{ secrets.DEPLOY_TOKEN }}
publish_branch: gh-pages
publish_dir: docs/redirect/
В Change redirect
по сути я просто подставляю актуальную версию в заготовленный шаблон, используя для этого регулярку и Python. Понятное дело это можно сделать на любом удобном для вас языке. Наконец в Deploy redirect
осуществляю заливку готового редиректа в корень gh-pages. То есть не забываем указать откуда будем деплоить
publish_dir: docs/redirect/
Допиливаем workflow
Представим теперь ситуацию, при которой у нас может быть запущен workflow (например в ручную) для деплоя документации для не самой актуальной версии (возможно нужно, что-то откорректировать в документации к одной из предыдущих версий проекта). Тогда после выполнения, в редиректе изменится версия на не актуальную. Опять дополним наш docs.yaml
финальный docs.yaml
name: Docs
on:
push:
branches: [ main ]
tags:
- 'v*.*.*'
permissions:
contents: write
jobs:
docs-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
continue-on-error: true
with:
ref: gh-pages
- name: Check gh-pages versions
uses: jannekem/run-python-script-action@v1
id: script
with:
script: |
import pathlib
import re
from packaging.version import parse, InvalidVersion
current_version, path = parse("${{ github.ref_name }}"), "./"
with pathlib.Path(path) as dir:
for file in dir.iterdir():
if file.is_dir():
try:
last_version = parse(file.name)
if current_version <= last_version:
set_output('is_new_version_docs', 'false')
exit()
except InvalidVersion:
pass
set_output('is_new_version_docs', 'true')
outputs:
is_new_version_docs: ${{ steps.script.outputs.is_new_version_docs }}
docs-gen:
needs: docs-check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 1
- uses: actions/setup-python@v3
with:
python-version: 3.7
- name: Install dependencies
run: |
pip install -r docs/requirements.txt
- name: Sphinx build
run: |
sphinx-build docs/source docs/_build
- name: Deploy docs
uses: peaceiris/actions-gh-pages@v3
with:
allow_empty_commit: true
destination_dir: ./${{ github.ref_name }}
force_orphan: false
keep_files: true
personal_token: ${{ secrets.DEPLOY_TOKEN }}
publish_branch: gh-pages
publish_dir: docs/_build/
- name: Change redirect
if: needs.docs-check.outputs.is_new_version_docs == 'true'
uses: jannekem/run-python-script-action@v1
id: script
with:
script: |
import re
path, pattern = "docs/redirect/index.html", re.compile(r"\{\s*%\s*latest-version\s*%\s*}")
with open(path, "r", encoding="utf8") as file:
content = pattern.sub("${{ github.ref_name }}", file.read())
with open(path, "w+", encoding="utf8") as file:
file.write(content)
- name: Deploy redirect
if: needs.docs-check.outputs.is_new_version_docs == 'true'
uses: peaceiris/actions-gh-pages@v3
with:
allow_empty_commit: true
force_orphan: false
keep_files: true
personal_token: ${{ secrets.DEPLOY_TOKEN }}
publish_branch: gh-pages
publish_dir: docs/redirect/
Во-первых, здесь можно увидеть еще одну джобу docs-check
в которой и будет лежать проверка на актуальность заливаемой версии. Делаю это как обычно средствами любимого Python'а :) Здесь отмечу данный шаг
- uses: actions/checkout@v3
continue-on-error: true
with:
ref: gh-pages
в котором ставлю continue-on-error: true
что бы чекаут не проваливался при первом деплое, когда еще может не быть ветки gh-pages. Также использую ref: gh-pages
так как нужна исключительна ветка с доками.
По сути, в данной джобе я просто парсю ветку gh-pages на предмет наличия в ней папки с более актуальной версией. По итогу формирую output is_new_version_docs
название которого говорит за себя (в нём будет лежать строкой либо true
, либо false
).
Наконец в нашу первоначальную джобу docs-gen
остается добавить строчку
needs: docs-check
что бы она ожидала выполнения предыдущей джобы, а также добавить условие
if: needs.docs-check.outputs.is_new_version_docs == 'true'
в шаги Change redirect
и Deploy redirect
.
Переключатель версий
Данный раздел скорее относится к Sphinx, однако я должен упомянуть. На сайте нам желательно иметь красивую "переключалку" между версиями. Изначально я думал для этого использовать sphinx-multiversion, который в нужном стиле при сборке будет добавлять эту самую "переключалку". Однако отказался от этого, так как sphinx-multiversion при построении переключателя версий учитывает только версии которые собирает в данный момент. И проблема именно в том, что пришлось бы всегда все пересобирать с нуля, а пропускать (имитировать) сборку нужной версии он не умеет (проблема обсуждается, например, здесь). То есть если будет огромный проект с кучей документацией для каждой версии, то сборочка мягко говоря немного затянется. Поэтому следуя ответу добавляем файл docs/source/_templates/layout.html
{% extends "!layout.html" %}
{% block menu %}
<style>
/* style mobile top nav to look like main nav */
.wy-nav-top {
background: {{ theme_style_nav_header_background }}
}
</style>
{{ super() }}
<!-- Add versions for selected branches + tags -->
<p class="caption"><span class="caption-text">Versions:</span></p>
<ul id="versions"/>
<script>
// Add any branches to appear in the side pane here, tags will be added below
// Will only appear if docs are built and pushed in gh-pages
var versions = ['master', 'main'];
var dirs = new Set();
function addVersion(name) {
if (dirs.has(name)) {
var li = document.createElement("li");
var a = document.createElement("a");
a.href = 'https://username.github.io/{{ project }}/' + name;
a.innerText = name;
li.appendChild(a)
document.getElementById('versions').appendChild(li);
}
}
Promise.all([
// Find gh-pages directories and populate `dirs`
fetch("https://api.github.com/repos/username/{{ project }}/contents?ref=gh-pages")
.then(response => response.json())
.then(data => data.forEach(function(e) {
if (e.type == "dir") dirs.add(e.name);
})),
// Add tags to `versions`
fetch('https://api.github.com/repos/reponame/{{ project }}/tags')
.then(response => response.json())
.then(data => data.forEach(function(e) {
versions.push(e.name);
}))
]).then(_ => versions.forEach(addVersion))
</script>
{% endblock %}
а в конфигурационном файле conf.py
Sphinx'а не забываем указать
templates_path = ['_templates']
Заключение
Очевидно, что в данной статье я охватил не все о чем можно было бы рассказать. Мультиязычная документация - проигнорирована мною, так как для моего проекта изначально и планировалось, что документация будет в одном варианте - в английском. Возможно в будущем вернусь к данному вопросу, может даже еще одну статью напишу :)
Надеюсь было интересно и полезно. Всем спасибо!