Недавно мне потребовалось собрать и развернуть документацию для одного из своих небольших проектов на 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']
Заключение
Очевидно, что в данной статье я охватил не все о чем можно было бы рассказать. Мультиязычная документация - проигнорирована мною, так как для моего проекта изначально и планировалось, что документация будет в одном варианте - в английском. Возможно в будущем вернусь к данному вопросу, может даже еще одну статью напишу :)
Надеюсь было интересно и полезно. Всем спасибо!
Комментарии (3)
dolfinus
11.09.2023 18:04Readthedocs создаёт под каждый проект поддомен, и они под блокировку не попадают. Надеюсь, так и будет дальше, не хотелось бы с него съезжать.
NightShad0w
Остроумное решение, но как же сомнительно выглядит Python код, который регулярными выражениями патчит файл на лету перед публикацией, посреди yaml разметки.
Но за все равно спасибо, что поделились опытом и наработками.
sammnnz Автор
Отчасти соглашусь на счет сомнительности, тоже первое время резало глаз :) Однако желание использовать Python и наличие экшона jannekem/run-python-script-action сделали свое дело. Как вариант, тем же экшоном можно запускать модули .py, вместо того что бы прямо в yaml писать Python код.