HERON

Спецификация 23

Поставка: HERON отдельно от сайтов

Эта спецификация фиксирует, как HERON попадает к вашим сайтам: HERON поставляется отдельно от сайтов, которые собирает. Один артефакт в двух упаковках, версии, команды, два выхода — папка или образ, конфиг nginx, который собирается вместе с сайтом, темы и плагины, формы и безопасность сборки.

Читайте её, когда выбираете версию, собираете образ, настраиваете сервер или удивляетесь, почему HERON отказывается делать что-то «для удобства».

Простыми словами

Сам HERON и ваши сайты хранятся отдельно. HERON — инструмент, который вы скачиваете (образ Docker или пакет Python); сайт — папка, в которой записано, какая версия HERON может его собирать. Новая версия HERON никогда не меняет живой сайт неожиданно: каждый сайт переходит на новую версию, только когда вы поменяете одну строку.

Результат HERON отдаёт двумя способами: папкой готовых файлов (для любого хостинга, GitHub Pages или Cloudflare Pages) или образом (файлы плюс веб-сервер, для своего сервера). Страницы внутри одинаковые.

Пример. Первая строка site.yaml закрепляет версию, а тег образа выбирает, какой HERON вы запускаете:

heron: ">=0.1,<0.2"     # этот сайт собирается любой версией 0.1.x
docker run --rm -v "$PWD":/site -w /site ghcr.io/fmstm/heron:prod build . --env prod --strict && ls dist

prod — стабильная версия, рекомендованная для живых сайтов. Остальные теги описаны в разделе версии HERON.

Когда нужна эта страница. Когда вы выбираете версию, собираете образ, настраиваете сервер или удивляетесь, почему HERON отказывается делать что-то «для удобства».

Версия HERON и окружение сайта — разные вещи

Окружение сайта (SITE_ENV) отвечает на вопрос, каким будет САЙТ: дев закрыт от индексации и собирается без счётчиков, прод открыт. Версия HERON отвечает на вопрос, каким КОДОМ его собрали.

Эти оси независимы, и связывать их нельзя. Человек, которому нужен закрытый от индексации сайт, чтобы спокойно поработать над контентом, не должен из-за этого получить сырую ветку разработки HERON и чужие ошибки. Дев-сайт собирается стабильной версией HERON — это нормальное, ожидаемое сочетание.

Пример. Один и тот же стабильный HERON собирает черновик и живую версию; отличается только окружение:

heron build . --env dev && heron build . --env prod --strict

Первая сборка закрыта от поисковиков и без счётчиков, вторая — та, которую видят посетители.

Сырую версию HERON берут только тогда, когда занимаются самим HERON, и делают это явно: ENGINE=local, ENGINE=dev, ENGINE=beta.

Почему отдельно

Прежнее решение — HERON, контент и тема в одном репозитории, разделение после запуска — было верным для монолита и неверно для pip-пакета. Сайт не содержит HERON, он его устанавливает. Стоимость разделения на старте — три строки в конфигурации; стоимость разделения потом — переписать Dockerfile, CI, документацию и разобрать историю коммитов.

Практическая причина сильнее теоретической: сайтов несколько. Копия ядра в каждом репозитории означает столько же расходящихся сборщиков. Правка парсера, сделанная на одном сайте, не доедет до остальных; публичный репозиторий будет отставать от того, на чём сайты реально работают; ссылка на него с этих сайтов будет работать против проекта.

Артефакты

Артефакт ровно один, в двух упаковках одного и того же кода.

Упаковка Адрес Для чего
Образ ghcr.io/OWNER/heron:<версия> сборка на любой машине без установки питона; прод и CI
Пакет pip install heron локальная разработка: правка контента без докера на каждый абзац

Внутри образа — Python, пакет heron, ENTRYPOINT ["heron"] и непривилегированный пользователь. Больше ничего.

Темы и плагины — отдельные переносимые единицы, поставляются пакетами или git-ссылками. В образ HERON они не вшиты.

Версии и совместимость

site.yaml первой строкой объявляет требуемую версию:

heron: ">=0.1,<0.2"

HERON сверяет себя с этим требованием до всего остального и при расхождении останавливается с E012, называя свою версию и требуемую. Это единственное, что не даст трём сайтам разъехаться по несовместимым версиям.

Версионирование семантическое. Ломающее изменение контракта папки, формата site.yaml, theme.yaml или набора хуков — это минорная версия до 1.0 и мажорная после. Изменение контракта без смены версии — ошибка релиза, а не мелочь.

Что значат теги prod, beta и dev на практике — версии HERON.

Команды

Команды HERON — new, init, check, serve, build, page — описаны в спецификации сборки, это вся его поверхность. Упаковки в образ среди них нет: её делает хост (выходы).

Два входа, один выход

# папки не было или в ней только git
heron new мой-сайт           # в терминале спросит название, домен, языки
heron new .                  # то же в текущей папке
heron build мой-сайт         # → мой-сайт/dist/
# поменяли языки или тему в site.yaml
heron init мой-сайт          # достроить папки под написанное

# папка с контентом уже есть
heron init ~/SOURCE/готовый-контент
heron check ~/SOURCE/готовый-контент
heron build ~/SOURCE/готовый-контент --env prod --out /tmp/site

То же самое образом — docker run … ghcr.io/fmstm/heron:prod <команда> (README, «Быстрый старт»), а для нескольких сайтов — scripts/site.sh (выходы).

init — та же раскладка, но для существующей папки: смотрит, что есть, дописывает недостающее, ничего не перезаписывает без --force и печатает отчёт о найденном и добавленном.

Клонирование репозитория HERON в папку сайта не является способом установки и не поддерживается. Способов ровно два: образ и pip-пакет. Клон репозитория HERON рядом с сайтами нужен только ради scripts/site.sh.

new: опрос в терминале, флаги без него

Человек, который заводит сайт, ещё не знает, какие ключи есть в site.yaml, — поэтому в терминале new спрашивает то, без чего сайт не начать: язык пояснений, название, домен, языки, основной язык. Ответы ложатся в site.yaml, и он остаётся единственным источником правды: всё прочее правится там, а heron init догоняет структуру папок.

Агенту, CI и docker run без -it вопросы задавать некому — там new неинтерактивен и детерминирован: значения из флагов --name, --domain, --lang en,ru, --notes, иначе по умолчанию. Это один путь, а не два: опрос лишь собирает те же аргументы, что и флаги, и зовёт тот же код. -y отключает вопросы и в терминале.

Язык пояснений — --notes ru|en или переменная HERON_NOTES; по умолчанию en, выбор запоминается в build.notes. В докере опрос требует -it:

docker run --rm -it -v "$PWD":/site -w /site --user $(id -u):$(id -g) ghcr.io/fmstm/heron:beta new .

Что кладут new и init

<сайт>/
├── site.yaml              версия HERON, название, домен, языки, тема, меню с главной
├── .gitignore             dist/, .heron-cache/
├── heron-readme.md        пояснительная записка — и в каждой папке ниже
├── content/<язык>/        index.md и 404.md с полным SEO-блоком — на каждый язык из site.yaml
├── data/  media/  static/  plugins/          пусто
└── theme/                 скелет: theme.yaml, base.html, templates/ (home, page, 404),
                           modules/, partials/ (header, footer, analytics), i18n/, assets/

Сборочной обвязки — Dockerfile, compose, build.sh, .env — в папке сайта нет и быть не должно (папка сайта): она живёт в репозитории HERON.

Два выхода: папка и образ

Выход выбирается командой сборки, а не при создании сайта.

Папка. heron build кладёт готовый сайт в dist/ (или в --out). Её можно отдать любым nginx, залить на шаред-хостинг, положить в статический хостинг — HERON при этом больше не нужен. Рядом с HTML лежит .heron-nginx.conf — конфиг сервера под этот сайт (ниже).

Образ. Образ nginx с сайтом внутри собирает хост из репозитория HERON:

./scripts/site.sh <сайт> build <окружение>   # → out/<сайт>-<окружение>
./scripts/site.sh <сайт> image <окружение>   # Dockerfile.site: nginx-unprivileged + статика + конфиг
./scripts/site.sh <сайт> smoke <окружение>   # поднять образ и проверить коды ответа
./scripts/site.sh <сайт> push <окружение>    # в реестр из REGISTRY

Контекст сборки образа — уже собранная папка out/<сайт>-<окружение>, а не папка контента: в образ физически не может попасть ни .git, ни исходники картинок, и HERON в работающем контейнере нет. Настройки — env/.env.<сайт>.<окружение> (пример с пояснениями — env/.env.example): путь к контенту, SITE_ENV, версия HERON (ENGINE), имя образа, реестр.

Из репозитория сайта образ собирает переиспользуемый workflow FMSTM/HERON/.github/workflows/site-image.yml: сборка HERON из образа, Dockerfile.site той же ветки HERON, дымовая проверка scripts/smoke-site.sh, отправка в реестр. Как подключить — сборка и выкладка и практический гайд публикация через git.

Своей команды упаковки у HERON нет и быть не должно: docker-сокет внутрь контейнера сборки не пробрасывается никогда (доступ к сокету равносилен руту на хосте), поэтому docker build выполняет хост.

Страница 404 и отдача

Статика сама по себе не умеет отвечать 404: сервер по умолчанию отдаёт свою страницу, и нарисованную темой не видит никто. Поэтому конфиг nginx собирается вместе с сайтом — HERON кладёт его в dist/.heron-nginx.conf, а образ переносит в conf.d и убирает из корня. Список языковых локаций берётся из site.yaml: основной язык живёт в корне, остальные в своих папках, и написанный руками список разошёлся бы с содержимым папки в первый же раз, когда основной язык сменят. Правила такие:

  • локации строятся по site.languages: корень — язык по умолчанию, плюс location ^~ /<язык>/ на каждый неосновной язык. Одноязычный сайт получает одну корневую локацию; языков, которых нет в списке, в конфиге нет;
  • перед локациями стоят карты 301 (redirect_from) и 410 (gone): ответ по ним уходит до обращения к файлам;
  • на несуществующий адрес отдаётся 404.html из корня сайта;
  • адрес с языковым префиксом получает 404 своего языка: /ru/… → /ru/404/index.html; адрес с префиксом основного языка папки не имеет и попадает на корневую 404;
  • код ответа остаётся 404. Заглушка с кодом 200 попадает в индекс и плодит дубли — это дороже любого удобства;
  • сама страница 404 помечена internal: открыть её по прямой ссылке как обычную страницу нельзя;
  • сжатие gzip для текста, стилей, скриптов, svg, карт и разметки; HTML — Cache-Control: no-cache (правка видна сразу, проверка свежести почти бесплатна), /media/ и /assets/ — public, max-age=86400: имена нарезок не меняются при замене содержимого, поэтому кэш «навсегда» показывал бы старую картинку;
  • размеры хэшей карт — в начале файла, в контексте http, до первой карты: map_hash_bucket_size и map_hash_max_size, посчитанные по ключам карт 301/410 (регулярки в хэш не входят). Корзина — степень двойки ≥ max(64, align8(8 + 2 + самый длинный ключ в байтах) + 8), с запасом на следующую степень, если ключ ближе 16 байт к границе; max_size — степень двойки ≥ max(2048, ключей × 2). По умолчанию nginx берёт корзину по кэш-линии процессора, и длинный кириллический адрес в неё не влезает — запустится конфиг или нет, решало бы железо. nginx: hash_sizes: off в site.yaml — директивы не пишутся (их задаёт nginx.conf хоста), отчёт называет нужные значения. Если на машине сборки есть nginx, собранный конфиг проверяется nginx -t; переиспользуемый workflow образа сайта прогоняет nginx -t на стоковом nginx до отправки образа;
  • скрытые файлы не отдаются вовсе — рядом со статикой лежит служебное, а папку могут разложить и руками на чужой сервер; /.well-known/ остаётся доступным, иначе не выпустить сертификат.

heron serve отдаёт ту же 404 и тем же правилом выбора языка: локальный просмотр не должен отличаться от того, что увидит посетитель. Собирает он при этом не в папку сайта, а во временную папку машины: папка контента монтируется только на чтение и принадлежит тому, кто правит текст, — просмотр не имеет права оставлять в ней ни dist, ни кэш. Карты 301 и 410 heron serve не изображает: он отдаёт файлы. Ответы сервера по redirect_from и gone проверяются на образе — scripts/site.sh <сайт> smoke <окружение> — или на любом nginx с собранным .heron-nginx.conf.

Метка сборки

Вопрос «почему правка не видна» почти всегда означает «отдаётся вчерашняя статика», а не «сборка сломалась». Поэтому окружение сборки несёт версию HERON, время сборки и, если рядом есть git, короткий коммит контента: env.version, env.built_at, env.revision и готовая строка env.stamp.

Показывать её или нет — дело темы, но показывать в проде нельзя: это служебная информация, а не часть сайта. Тема выводит метку под условием not env.is_prod.

Проверки и дымовой прогон

Набор проверок один и живёт в одном месте — scripts/check.sh: граница ядра по всему дереву, формат, линтер, тесты. CI зовёт тот же скрипт. Два набора — локальный и «как в CI» — расходятся в первый же день: локально гоняли линтер, а падало на форматировании.

./scripts/site.sh <сайт> smoke <окружение> поднимает собранный образ, дожидается ответа и сверяет коды: корень и robots.txt — 200, несуществующий адрес — 404, языковой раздел — 200 и 404 на своём несуществующем, служебный конфиг рядом со статикой — 404, каждый адрес из redirects.map — 301 туда, куда объявлено, каждый точный адрес из gone.map — 410. Кириллица в адресах кодируется перед запросом. Контейнер гасится, логи показываются при падении.

Проверка заводится не от любви к автоматике: «контейнер умирает на старте» ловили браузером заказчика. Права на файлы, неподхваченный конфиг и упавший nginx снаружи выглядят одинаково — пустой страницей.

Теги образа

CI вписывает в образ ветку, коммит и время сборки (HERON_CHANNEL, HERON_REVISION, HERON_BUILT), и heron --version их показывает: 0.1.0 (beta, 55249f0, 2026-10-08 19:12 UTC). Дымовая проверка CI сверяет, что в образе отметка именно собранного коммита.

Публикуются: имя ветки, точная версия по тегу vX.Y.Z, latest для тега и короткий sha. Подвижного X.Y нет намеренно: сайт объявляет требование к HERON в site.yaml и должен иметь возможность закрепиться на том, что не поедет под ним завтра.

Поиск тем и плагинов

Один механизм на обе сущности — второго не заводим, иначе придётся помнить, какой где.

Порядок разрешения, первый найденный выигрывает:

  1. локально в папке сайта — theme/ для темы, plugins/<имя>/ для плагина;
  2. установленный пакет — heron-theme-<имя>; для плагина — entry point группы heron.plugins;
  3. git-ссылка, если указана явно в site.yaml.

Локальное всегда сильнее. Тема разрабатывается прямо в сайте, без публикации на каждую правку CSS; вынести её в пакет можно позже, не меняя ни строчки в контенте.

Плагины — обычные pip-пакеты, устанавливаются в образ сайта одной строкой Dockerfile. Своего формата пакетов и своего загрузчика HERON не изобретает. Локальная папка plugins/ — для одноразовых плагинов конкретного сайта, которые публиковать незачем.

Тема не найдена — E013. Плагин из plugins: не установлен или упал при загрузке — E014.

Что можно расширять, а что нельзя

Набор хуков и их смысл — сборка, точки расширения.

Главное разграничение, которое нельзя размывать:

  • Время сборки — хуки сборки. Свои типы секций, свои связи, свои фильтры Jinja, свои генераторы. Работают внутри восьми этапов конвейера.
  • Время работы сайта — всё, что происходит, когда посетитель уже на сайте. Хуков HERON здесь нет и быть не может: в этот момент HERON отработал и вышел.

«Плагин форм для статического генератора» — противоречие в терминах. Приём POST живёт в heron-relay.

Формы и heron-relay

Отдельный образ, один процесс, без базы. Появляется на сайтах, где есть формы; сайт без форм не разворачивает его вовсе.

Схема: статическая страница шлёт POST → relay валидирует по описанию формы из site.yaml → антиспам → рассылает по приёмникам.

forms:
  callback:
    fields: [name, phone, message]
    sinks: [mail, crm, telegram]

sinks:
  mail:     {to: info@example.com, smtp_env: SMTP_URL}
  crm:      {webhook: https://crm.example.com/hook, secret_env: CRM_SECRET}
  telegram: {chat_id_env: TG_CHAT}

Антиспам: honeypot-поле, минимальное время заполнения, ограничение частоты по адресу. Капча (Turnstile) — опционально, включается в конфиге, по умолчанию выключена.

Приёмники независимы: недоступность CRM не отменяет письма. Ошибка любого приёмника не возвращается посетителю как ошибка отправки — он свою часть сделал.

Про базу. Relay остаётся без состояния. SQLite подключается только как спул повторной отправки, когда приёмник лежит: заявка не теряется и досылается. Это не хранилище заявок — хранилище это CRM.

Черта, которую нельзя переходить: как только в базу поедет контент или настройки сайта, HERON превращается в WordPress. Файлы — единственный источник истины.

Где сейчас relay и необязательное подключение данных — план развития.

Модель безопасности

Сборщик статики — процесс, которому не нужно ничего. Поэтому забираем всё.

docker run --rm \
  --network=none \
  --read-only --tmpfs /tmp \
  --cap-drop=ALL --security-opt=no-new-privileges \
  --user "$(id -u):$(id -g)" \
  -v "$PWD:/site" \
  ghcr.io/OWNER/heron:0.1.0 build --strict
Мера Что даёт
--network=none сборка читает и пишет только локальные файлы. Сети ей не нужно ни на одном этапе. Скомпрометированная зависимость не сольёт и не притащит ничего. Побочно дисциплинирует дизайн: подтянуть шрифт или картинку «на этапе сборки» физически нельзя
--user $(id -u):$(id -g) dist/ создаётся владельцем папки, а не рутом. В образе USER heron, но флаг важнее: без него на Linux-VPS результат сборки принадлежит чужому uid. Образ не должен зависеть от конкретного $HOME — иначе сломается с произвольным uid
--read-only + tmpfs записываемы только /site/dist и /site/.heron-cache — сборка и кэш нарезок. Кэш переживает rm -rf dist намеренно: в нём всё, что дорого посчитать, и удалить его безопасно в любой момент
--cap-drop=ALL генератору HTML не нужна ни одна capability
no-new-privileges никакого повышения через suid
нет .env в сборке секретов у сборки нет; .env монтируется только в relay
нет docker-сокета никогда, ни при каких условиях

Рантайм: nginxinc/nginx-unprivileged, слушает 8080 не от рута. Официальный nginx стартует мастер-процессом от рута ради 80-го порта — здесь это не нужно, впереди прокси.

Сырой HTML в markdown выключен по умолчанию (build.allow_raw_html: false, ошибка E015). При переносе контента из WordPress туда приедет тег script от какого-нибудь виджета, и лучше, чтобы сборка на этом упала, чем молча вклеила.

Цепочка поставки: зависимости залочены (uv.lock), базовый образ прибит по digest, а не по тегу, образ собирается в CI из публичного репозитория. Тогда heron:0.1.0 воспроизводим, и можно доказать, что в нём то же, что в гите.

Граница ядра проверяется машиной. В репозитории HERON стоит pre-commit, запрещающий коммит с упоминанием любого из сайтов на HERON, имён клиентов и телефонов. Список хранится хешами (scripts/boundary-hashes.txt) — сами имена в публичный репозиторий не попадают; добавление нового сайта — правка этого файла, а не хука. Дисциплина не масштабируется, хук масштабируется.

Репозитории

Репозиторий Содержимое Видимость
HERON heron/ (contracts, core, modules, cli, scaffold), tests/, docs/, scripts/site.sh, Dockerfile, Dockerfile.site публичный
репозиторий сайта контент, данные, картинки, тема — без сборочной обвязки на усмотрение владельца сайта

Для правки HERON рядом с сайтом его ставят editable-инсталлом (uv pip install -e ../HERON) или собирают из исходников через ENGINE=source в scripts/site.sh: правки видны сразу, без публикации образа.

Что за рамками

  • Мультисайт в одной папке. Одна папка — один сайт, жёстко. Мультисайт удваивает сложность резолва путей ради экономии, которой на трёх разных сайтах нет.
  • Админка. Отложена до появления реальной нужды. Правило на момент, когда она появится: правит файлы и коммитит, в базу не пишет.
  • Инкрементальная сборка. На объёме в сотню страниц не нужна. serve пересобирает всё.

Источник

Эта страница повторяет docs/spec/23-distribution.md из ветки prod репозитория HERON (документ 4 из 4; дата 2026-09-14, разделы 3–5 приведены к коду 2026-10-04). Первым обновляется репозиторий, эта страница — сразу следом. Последняя версия всегда здесь: 23-distribution.md на GitHub.