↓ Перейти к основному содержимому
  1. Posts/
  2. Self-Hosting/

Убираем лишнее звено: переезжаем со своего Forgejo-раннера на self-hosted GitHub Actions

·1830 слов·9 минут· loading · loading · ·
Stilicho2011
Автор
Stilicho2011
Пишу о homelab, self-hosting, автоматизации и open-source решениях
Оглавление
Автоматизация публикации ProHomelab - This article is part of a series.
Part : This Article

С чем пришли к этой статье
#

В первой статье цикла я настроил CI/CD на Forgejo: раннер на systemd в LXC, два репозитория - prohomelab-content и prohomelab-site - и API-триггер workflow_dispatch между ними, потому что Forgejo не умел repository_dispatch. Во второй статье я перенес хостинг с Smartape (FTP) на GitHub Pages, но саму сборку сайта оставил на том же Forgejo-раннере.

На бумаге план этой, третьей части выглядел просто и красиво: убрать собственный «самодельный» раннер вообще, пересобирать сайт силами облачных раннеров самого GitHub Actions, а Forgejo оставить пассивным зеркалом - резервной копией на случай, если с GitHub что-то случится. По пути план поменялся трижды, один раз я всерьез испугался, что потерял все статьи, и в итоге пришел к схеме, которая отличается от изначальной задумки почти во всем - но реально работает. Точнее, работала, пока я не решил все переиграть еще раз (это уже тема следующей статьи). Дальше будет много суровой правды, как я прошелся по всем граблям, по которым только можно было пройтись, а там где их не должно было быть, я нашел, принес и наступил.

Попытка первая: облачный раннер, два репозитория как раньше
#

Стартовая идея была самой очевидной из возможных. Раз GitHub Actions сам предоставляет раннеры, зачем держать свой, правильно? Я с помощью ИИ написал workflow, который запускается на ubuntu-latest. Это не моя машина и не LXC, а одноразовая облачная виртуалка, GitHub поднимает ее под конкретный запуск и «убивает» ее сразу после его завершения. Раз машина одноразовая, Hugo на ней приходилось ставить с нуля при каждом запуске.

Структуру репозиториев на этом шаге я не трогал - как и в первых двух статьях, контент и сам сайт жили раздельно: prohomelab-content и prohomelab-site. Мостом между ними оставался отдельный скрипт scripts/sync-content.sh. Он клонирует content-репозиторий, заливает его содержимое rsync-ом в папку content/posts внутри site-репозитория и на ходу переписывает Obsidian-вики-ссылки на картинки через sed - без этого шага Hugo просто не понимает, куда ведут ссылки на изображения, вставленные из Obsidian. Ну, то есть, Hugo-то все равно, но по итогу получаются битые ссылки.

Сам скрипт синхронизации сработал сразу и без единой проблемы. А вот сборка сайта - нет. В workflow была зашита конкретная версия Hugo, 0.140.0, а тема Blowfish к тому моменту уже требовала минимум 0.158.0+. Из-за этого несоответствия сборка падала на функции try в шаблонах темы - старая версия Hugo попросту не понимала такой синтаксис. Лечение заняло одну строчку: поменял номер версии в самом workflow-файле на актуальную 0.164.0, и сборка наконец прошла. Вот и верь после этого ИИ, да?!

Тогда же стало ясно, что часть моего первоначального плана была лишней. Я собирался «на будущее» слить content и site в один репозиторий, но на практике в этом не было смысла: sync-content.sh и так тянет контент из отдельного репозитория, независимо от того, кто собирает сайт - Forgejo Actions или GitHub Actions. Первая рабочая версия прекрасно обошлась без слияния репозиториев вообще.

Почему я все-таки объединил репозитории и перешел на self-hosted раннер
#

Решение заработало, но что-то во всем этом было не так, было какое-то чувство неудовлетворенности. У меня уже был полностью настроенный LXC-контейнер с Hugo и темой Blowfish - тот самый, на котором раньше крутился Forgejo-раннер. Ставить Hugo с нуля на эфемерной облачной машине, которая еще и собирается не пойми сколько по времени, при каждом пуше, когда под рукой уже лежит готовая настроенная среда, стало ощущаться бессмысленной тратой времени. Решение было логичным. Вроде бы логичным. Надо зарегистрировать этот же LXC как self-hosted раннер для GitHub Actions, чтобы сборка снова происходила локально, а не в облаке.

И здесь появилось второе последствие моих шальных мыслей. Если сборка снова происходит на той же машине, где физически лежат исходники сайта, деление на два репозитория - content отдельно от site - перестает давать хоть что-то, кроме лишнего слоя синхронизации, который я только что чинил выше. Поэтому я слил оба репозитория на GitHub в один, prohomelab-pages-new. Obsidian теперь пушит прямо в него, а скрипт sync-content.sh стал просто не нужен.

Регистрация self-hosted раннера
#

Важный нюанс - GitHub-раннер отказывается стартовать от root (Must not run with sudo - это не про sudo буквально, а про то, что процесс идет от UID 0). Пришлось завести отдельного пользователя:

useradd -m -s /bin/bash ghrunner
chown -R ghrunner:ghrunner /home/actions-runner
su - ghrunner
cd /home/actions-runner
./config.sh --url https://github.com/<user>/prohomelab-pages-new --token <токен>
exit
cd /home/actions-runner
./svc.sh install
./svc.sh start

GitHub честно предупреждает, что self-hosted раннеры на публичных репозиториях - потенциальная дыра в безопасности, так как форк может через pull request выполнить произвольный код на твоей машине. У меня это не то, чтобы реальный риск на практике, потому что workflow триггерится только на push в main и workflow_dispatch, без pull_request - у чужого форка просто нет возможности его запустить. Но если я когда-нибудь добавлю pull_request-триггеры, к этому надо было бы вернуться. А для этого, я должен был бы это запомнить. В общем, не вариант.

После регистрации раннера deploy.yml заметно похудел: больше не нужны ни установка Hugo (он уже стоит на LXC), ни синхронизация контента (он теперь просто часть самого репозитория):

name: Build and Deploy

on:
  push:
    branches: [main]
  workflow_dispatch:

permissions:
  contents: read
  pages: write
  id-token: write

concurrency:
  group: "pages"
  cancel-in-progress: false

jobs:
  build:
    runs-on: self-hosted
    steps:
      - name: Checkout site repo
        uses: actions/checkout@v4
        with:
          submodules: recursive
          fetch-depth: 0

      - name: Setup Pages
        id: pages
        uses: actions/configure-pages@v5

      - name: Build with Hugo
        run: hugo -D --minify --baseURL "${{ steps.pages.outputs.base_url }}/"

      - name: Upload artifact
        uses: actions/upload-pages-artifact@v3
        with:
          path: ./public

  deploy:
    needs: build
    runs-on: self-hosted
    environment:
      name: github-pages
      url: ${{ steps.deployment.outputs.page_url }}
    steps:
      - name: Deploy to GitHub Pages
        id: deployment
        uses: actions/deploy-pages@v4

Очередные грабли по пути к сансаре
#

Схема заработала, но до финального вида добралась не сразу - по дороге было три отдельные проблемы, и каждая научила меня чему-то своему. Хотя, нет, вру, все они меня научили одному, работает, не трогай.

Забытый нюанс. Source в GitHub Pages
#

В процессе экспериментов я на время переключил Source репозитория с «GitHub Actions» на «Deploy from a branch» - и тут же получил падающую сборку с ошибкой Jekyll (GitHub Pages: jekyll v3.10.0 ... Rendering: themes/blowfish/exampleSite/...). Оказалось, это два независимых механизма: пока Source стоит на «Deploy from a branch», GitHub пытается собрать репозиторий сам, через встроенный Jekyll, вообще не глядя на deploy.yml. Как только я вернул Source обратно на «GitHub Actions» - Jekyll-сборка перестала мешать, а старые ее прогоны в истории Actions можно было смело игнорировать.

Тайный алиас: published в Hugo - это не просто дата
#

Самая неприятная и самая поучительная часть. Один из постов не собирался с ошибкой:

ERROR the "published" front matter field is not a parsable date

Причина - историческая особенность Hugo: поле published в его конфигурации фронтматтера зарезервировано как алиас сразу для трех полей - date, lastmod и publishDate. Ко всему прочему кое-где остались метаданные еще от сайта на базе Astro. Если в статье стоит published: true (булево, по привычке из Jekyll) вместо реальной даты - Hugo пытается распарсить true как дату и падает. Я потратил время, пытаясь переопределить это через [frontmatter] в hugo.toml:

[frontmatter]
  date = ["date", "publishdate", "pubdate", "lastmod", "modified"]
  lastmod = [":git", "lastmod", "modified", "date", "publishdate", "pubdate"]
  publishDate = ["publishdate", "pubdate", "date"]
  expiryDate = ["expirydate", "unpublishdate"]

По документации это должно было убрать published из всех трех списков алиасов. На практике не помогло: ошибка повторялась даже на свежем коммите с этим конфигом. Судя по открытому issue в трекере Hugo, обработка published частично зашита в код отдельным спецкейсом, а не только через настраиваемые алиасы. В итоге рабочим оказался куда более простой путь - не бороться с Hugo, а просто выставить в published настоящую дату, как и во всех остальных постах. Конфиг я вернул к исходному виду.

Если у тебя в архетипе или старых заметках закралось published: true - ищи так:

grep -rl "^published: true$" content/

Obsidian и слишком глубокая структура папок
#

Раз content теперь часть общего репозитория с темой Blowfish внутри (сотни файлов), просто клонировать все в vault нельзя - Obsidian попытается проиндексировать и тему тоже. Решение - git sparse-checkout в режиме cone: он материализует на диске только нужную папку, остальной репозиторий остается невидимым для файловой системы.

git clone --no-checkout --filter=blob:none https://<токен>@github.com/<user>/prohomelab-pages-new.git Prohomelab
cd Prohomelab
git sparse-checkout init --cone
git sparse-checkout set content/posts
git checkout main

Работает надежно, но и у этого способа есть цена. В vault появляется лишний уровень вложенности - Prohomelab/content/posts/<категория>/... вместо привычного плоского Prohomelab/<категория>/..., как было при отдельном content-репозитории на Forgejo.

Первая идея (которая не сработала) - спрятать эту глубину через NTFS junction. План был такой: переименовать реальный репозиторий в скрытую папку, а поверх его content/posts навести mklink /J, чтобы Obsidian видел привычную плоскую структуру напрямую. Для git это сработало бы: он ищет .git, поднимаясь по дереву папок вверх, и junction ему не мешает.

А вот Obsidian такие папки вообще не показывает в файловом дереве - даже когда на диске все на месте и полностью рабочее. Со стороны это выглядит как настоящая катастрофа: папка с сотней статей просто исчезает из vault, хотя на диске и в git все цело. Я на несколько минут успел всерьез испугаться, что потерял архив.

Пришлось откатить junction обратно и на сегодня смириться с лишним уровнем вложенности. Правильное решение - не файловый трюк на уровне ОС, а переопределить contentDir в конфиге Hugo так, чтобы контент физически лежал ближе к корню репозитория. Это отдельная, более аккуратная перестройка, которую я на тот момент отложил - и, как показала история, правильно сделал.

Итоговая схема
#

Obsidian (Windows, sparse-checkout) → push
        │
        ▼
GitHub: prohomelab-pages-new (content + site + тема-submodule, единый репозиторий)
        │
        ▼
GitHub Actions - self-hosted runner прямо на LXC (тот же, где раньше стоял forgejo-runner)
        │  build: checkout → configure-pages → hugo --minify → upload-pages-artifact
        │  deploy: deploy-pages
        ▼
GitHub Pages → prohomelab.com

Forgejo - pull mirror всего репозитория (New Migration → mirror), обновляется по расписанию, полностью пассивен

forgejo-runner на LXC остановлен и отключен (systemctl stop/disable) - вместо него на той же машине живет actions.runner.* от GitHub, под отдельным непривилегированным пользователем. Старый Forgejo-репозиторий prohomelab-site не нужен в активном виде - вместо него настроен Pull Mirror: новый репозиторий в Forgejo (New Migration → This repository will be a mirror), который сам, по расписанию, забирает изменения с GitHub. Важно: это не «Push mirror» - все-таки такой настройки в самом GitHub не существует, а зеркалирование настраивается только со стороны Forgejo/Gitea.

Что в итоге изменилось по сравнению с планом двух прошлых статей
#

Было (после статьи 2)Стало
Репозитории2 (prohomelab-content, prohomelab-site)1 (prohomelab-pages-new)
РаннерForgejo-раннер на LXC (свой)self-hosted GitHub Actions раннер на том же LXC
Установка Hugo в CIпри каждом запускене нужна - уже стоит на машине
Синк контентаотдельный шаг sync-content.shне нужен - контент часть репозитория
Forgejoактивный узел пайплайнаPull Mirror, полностью пассивен
Obsidianпуш в отдельный content-репозиторийsparse-checkout, пуш прямо в общий репозиторий

Три статьи цикла в сумме - это путь от «пишу в Obsidian → руками копирую → руками собираю → руками заливаю по FTP» до полностью автоматического: пишу заметку → она сама появляется на сайте. Инфраструктура при этом не разрослась, а наоборот - сжалась до одного репозитория и одного раннера на уже существующем железе. Цена - несколько нервных часов, стоящих мне пару дней жизни, по пути, и одна ложная тревога с «пропавшими» статьями, которая оказалась просто особенностью того, как Obsidian работает (точнее, не работает) с NTFS junction.

Автоматизация публикации ProHomelab - This article is part of a series.
Part : This Article

Related

ProHomelab: автоматизация публикации статей через Forgejo

·4948 слов·24 минут· loading · loading
Полностью автоматизирую публикацию ProHomeLab: пишу статью в Obsidian, изменения автоматически попадают в Forgejo, Forgejo Actions запускает сборку Hugo на Blowfish LXC, а готовый сайт без ручного участия загружается на хостинг от Smartape.

Установка Hugo + тема Blowfish с нуля: подробная инструкция

··2434 слов·12 минут· loading · loading
Разворачиваю Hugo Extended и тему Blowfish с чистого листа: от подготовки LXC и установки Hugo до создания сайта, подключения темы, настройки структуры контента и первой production-сборки.

GitOps для homelab: разворачиваем Forgejo и Komodo с нуля

··5745 слов·28 минут· loading · loading
Подробный туториал по GitOps для начинающих: разворачиваем Forgejo как собственный git-сервер, Komodo для управления Docker-стеками и настраиваем автодеплой по push, с разбором каждого параметра конфигурации.