«Мультиверсионная» документация с GitHub Pages

—

от автора

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

Заключение

Очевидно, что в данной статье я охватил не все о чем можно было бы рассказать. Мультиязычная документация — проигнорирована мною, так как для моего проекта изначально и планировалось, что документация будет в одном варианте — в английском. Возможно в будущем вернусь к данному вопросу, может даже еще одну статью напишу 🙂

Надеюсь было интересно и полезно. Всем спасибо!


ссылка на оригинал статьи https://habr.com/ru/articles/760292/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *