Спецификация 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 и должен иметь возможность закрепиться на том, что не поедет под ним завтра.
Поиск тем и плагинов
Один механизм на обе сущности — второго не заводим, иначе придётся помнить, какой где.
Порядок разрешения, первый найденный выигрывает:
- локально в папке сайта —
theme/для темы,plugins/<имя>/для плагина; - установленный пакет —
heron-theme-<имя>; для плагина — entry point группыheron.plugins; - 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.