Недавно мне потребовалось собрать и развернуть документацию для одного из своих небольших проектов на 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)


  1. NightShad0w
    11.09.2023 18:04

    Остроумное решение, но как же сомнительно выглядит Python код, который регулярными выражениями патчит файл на лету перед публикацией, посреди yaml разметки.

    Но за все равно спасибо, что поделились опытом и наработками.


    1. sammnnz Автор
      11.09.2023 18:04

      Отчасти соглашусь на счет сомнительности, тоже первое время резало глаз :) Однако желание использовать Python и наличие экшона jannekem/run-python-script-action сделали свое дело. Как вариант, тем же экшоном можно запускать модули .py, вместо того что бы прямо в yaml писать Python код.


  1. dolfinus
    11.09.2023 18:04

    Readthedocs создаёт под каждый проект поддомен, и они под блокировку не попадают. Надеюсь, так и будет дальше, не хотелось бы с него съезжать.