Недавно мне потребовалось собрать и развернуть документацию для одного из своих небольших проектов на 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']
Заключение
Очевидно, что в данной статье я охватил не все о чем можно было бы рассказать. Мультиязычная документация - проигнорирована мною, так как для моего проекта изначально и планировалось, что документация будет в одном варианте - в английском. Возможно в будущем вернусь к данному вопросу, может даже еще одну статью напишу :)
Надеюсь было интересно и полезно. Всем спасибо!
