# Конвейер поставки: манифест, сборщик, проверяльщик, гейт

Документ отвечает на один вопрос: **как из живого дерева получается публикуемое
и что доказывает, что полученное можно отдавать**. Он появился потому, что
публикация собиралась руками: снимок `.staging/release-v0.2` сделан 14.10.2026
и к 24.10 отстал на десять дней — обвязки (`CHANGELOG`, `CONTRIBUTING`,
`SECURITY`, `ROADMAP`, `README.ru`, `CITATION`, `CODE_OF_CONDUCT`, `mkdocs.yml`,
`.github/**`) в живом дереве не было вовсе, установщик существовал в трёх версиях
(34 242 / 37 794 / 51 062 байта), а живой `ci.yml:125` вызывал `-SummaryJson`,
которого живой установщик не объявлял.

Замер 24.10.2026 на этом дереве, после возврата трёх файлов в поставку и замены
живого конфига примером: манифест — 38 включений и 27 исключений, сборка —
**639 файлов (7,8 МБ)**, проверяльщик — 8 правил, **осмотрено 4 585** объектов,
гейт — **4 из 4** пойманных порчи.

Точный объём в байтах здесь не назван намеренно: этот документ сам входит
в сборку, поэтому любая его правка меняет сумму. Число файлов и хэш файла
контрольных сумм — тоже изменяемые, но они проверяются, а не запоминаются:
`checksums.sha256` строится заново при каждой сборке.

## 1. Кто что делает

| файл | роль | что не делает |
|---|---|---|
| `tools/release/manifest.toml` | отображение «путь в живом дереве → путь в поставке», исключения с причинами | не содержит списка файлов поштучно: каталоги заданы шаблоном |
| `tools/release/relcommon.py` | разбор манифеста и сбор кандидатов — ОДИН экземпляр для трёх инструментов | не решает, что публиковать: это манифест |
| `tools/release/build_release.py` | собирает дерево в указанный каталог, пишет `checksums.sha256` | не проверяет собранное (это делает проверяльщик) |
| `tools/release/verify_release.py` | девять правил: наличие, хэш, лишнее, исключённое, разбор, секреты, кодировка, таблица источников, пути машины автора | не сверяется с GitHub: сети у него нет |
| `tools/release/gate_release.py` | доказывает, что проверяльщик не молчит: пять порч и две приманки | не публикует и не коммитит |
| `tools/release/manifest-enterprise.toml` | манифест единственной закрытой поставки: наследует манифест Community, объявляет закрытые модули и папку `enterprise/` | не описывает права лицензии: это `edition_baseline.json` |
| `tools/release/check_pro_delivery.py` | закрытая поставка: воспроизводимость, состав, привязка лицензии в собранном дереве | не выполняет платные операции: проверяется разрешение, а не провайдер |
| `tools/check_release_manifest.py` | 25-я проверка чекера: манифест↔дерево, сборка, гейт, поставка Pro | не запускается сама — её запускает `Invoke-ProjectDoctrine.ps1` |

Почему `relcommon.py` отдельным файлом, хотя в задании его не было. Три
инструмента обязаны понимать манифест одинаково. Разошедшись, они дали бы ту же
болезнь, из-за которой появился весь этап: три версии установщика, каждая из
которых считала себя правильной.

## 2. Как собрать

```powershell
# песочница, не живое дерево
python tools\release\build_release.py --out $env:TEMP\ragraf-release-sbx
python tools\release\verify_release.py --tree $env:TEMP\ragraf-release-sbx
python tools\release\gate_release.py

# в непустой каталог — только с --force: прежнее содержимое переносится
# в <приёмник>.bak-<метка времени>, и сборщик печатает, что именно убрано
python tools\release\build_release.py --out D:\rel --force
```

Сборщик **отказывается** собирать, если приёмник пересекается с живым деревом
(в том числе `.staging/**`): такая сборка попала бы в собственный индекс RAGRAF,
и следующая реиндексация считала бы её частью корпуса.

Замер 24.10.2026: сборка прямо в `.staging/release-v0.2/RAGRAF` завершается кодом
2 — «приёмник пересекается с живым деревом». Значит команда
`build_release.py --out .staging/… --force` **невыполнима по построению**, и снимок
собирается иначе:

1. сборка идёт в каталог ВНЕ живого дерева (`%TEMP%\ragraf-release-v0.2`);
2. её проверяет `verify_release.py`, гейт — `gate_release.py`;
3. содержимое заменяет рабочее дерево клона `.staging/release-v0.2/RAGRAF`, при
   этом каталог `.git` клона сохраняется;
4. прежнее содержимое клона остаётся рядом как `<клон>.bak-<метка>`.

Так состав поставки остаётся результатом сборки, а не ручной правкой.

Проверку манифеста можно навести на другую копию: `python tools\check_release_manifest.py --manifest <путь>` — так испорченный манифест проверяется на этом же дереве, без временной правки живого файла (замер: нечестная пометка `guard` при двух отсечённых файлах даёт ровно одну строку `FAIL` и код 1).

Коды возврата у всех трёх: `0` — успех, `1` — дефект дерева или манифеста,
`2` — ошибка аргументов (включая непустой приёмник без `--force`), `3` —
окружение (нет `pwsh`, нет прав на запись, нет TOML-разбора).

## 3. Девять правил проверяльщика

| правило | что ловит | измеренный пример |
|---|---|---|
| `presence` | удалённый файл | гейт: удаление `docs/00_plan/PLAN-IAM-YOU-GLOBAL-MAP.md` |
| `hash` | подмена содержимого | гейт: подменённый байт в `.github/PULL_REQUEST_TEMPLATE.md` |
| `coverage` | файл вне манифеста | гейт: `injected-report.txt` в корне сборки |
| `excluded` | путь из списка исключений | гейт: `docs/AGENTS-context.md` и `trap.bak` |
| `syntax` | неразбираемый скрипт или разметка | `*.py` компилируется, `*.ps1` разбирается парсером PowerShell (один процесс на все файлы), `*.yml`/`*.cff` — YAML, `*.json` — JSON |
| `secrets` | секретоносное ЗНАЧЕНИЕ; подставное значение — только с маркером и только в объявленных файлах | нашёл три файла (раздел 5); после правки этапа 4 — ни одного |
| `encoding` | BOM и CRLF у текстовых файлов | нашёл `admin/static/tailwind.src.css` — единственный текстовый файл с CRLF под индексируемыми корнями |
| `sources` | таблица `RELEASE-SOURCES.md` расходится со сборкой | ловит и устаревший хэш, и строку про файл, которого в поставке нет |
| `paths` | путь машины автора: внутрь домашнего каталога и внутрь приватного дерева | нашёл 86 файлов (25 + 70, 9 пересекались); после правки — 0. Гейт ловит порчу «путь машины» |

Два правила пришлось переформулировать по факту первого прогона — это записано
здесь, потому что повторять эти ошибки незачем:

* `presence` сначала проверяло «каталог манифеста дал хотя бы один файл» и
  пропускало удаление отдельного файла внутри каталога. Гейт поймал: порча 3 из 4
  проходила как годное дерево. Правильная формулировка — «каждый разрешённый путь
  на месте»;
* `secrets` сначала не знало поля `token` без приставки — и гейтовая порча
  «секрет-строка» проходила. После расширения форм правило нашло два настоящих
  литеральных значения в дереве (раздел 5).
* `secrets` требовало границу слова прямо перед именем поля и поэтому **не ловило**
  `MYSQL_PASSWORD = "…"`. Это измеренный дефект этапа 4: настоящее значение доступа
  к тестовой СУБД правило не находило, зато находило три ПОДСТАВНЫХ значения в
  проверках отказа — и по ним три файла были исключены из поставки. Исправлено
  двумя правками: 1) приставка перед именем поля допускается (`MYSQL_PASSWORD`,
  `DB_TOKEN`, `ADMIN_SECRET`); 2) подставное значение объявляется маркером
  константа `PLACEHOLDER_MARK` в проверяльщике, который действует только в файлах из
  `PLACEHOLDER_FILES` и обязан там присутствовать. Маркер в необъявленном файле и
  файл из списка без маркера — одинаково провал, поэтому список не стареет молча.

* `paths` появилось на этом этапе, и его формулировка взята не с потолка: она
  дословно повторяет правило обвязки из `tools/check_community_wrapper.py`.
  Одна форма обязана ловиться одинаково в обвязке и в поставке — иначе
  «в обвязке чисто» перестаёт что-либо значить. Замер прошлого состояния:
  86 файлов с путями автора, из них 21 в документации, 136 изменённых
  файлов кода скомпилировано после правки, 2 скрипта `.ps1` разобраны.

Границы названы честно: правило `encoding` смотрит только текстовые расширения
(при первом прогоне оно красило двоичные `*.sqlite` и `*.bin` за случайную пару
байт `0D 0A`); правило `syntax` для YAML терпит теги инструментов
(`!!python/name:…` в `mkdocs.yml`), потому что файл исправен для mkdocs, а не для
нас.

## 4. Гейт: пять порч и приманки

Гейт собирает дерево, убеждается, что оно годное, и вносит порчи по одной,
каждый раз вызывая проверяльщик **с тем правилом, за которое порча отвечает**
(`--only=<правило>`). Иначе порча «секрет-строка» падала бы на несовпадении хэша,
и нельзя было бы сказать, что правило секретов вообще работает.

| № | порча | ожидание | замер 24.10.2026 |
|---|---|---|---|
| 1 | подмена байта | `hash` падает | поймано |
| 2 | лишний файл `injected-report.txt` | `coverage` падает | поймано |
| 3 | удалённый файл | `presence` падает | поймано |
| 4 | секрет-строка в `.md` | `secrets` падает | поймано |
| 5 | путь машины автора в `.md` | `paths` падает | поймано |
| + | путь из исключений в сборке | `excluded` падает | поймано (защита в глубину) |

После каждой порчи дерево восстанавливается, и гейт требует, чтобы оно снова
стало годным: иначе «падение» могло быть следствием предыдущей порчи.

Приманки — «ловится всё подряд» такой же дефект, как «не ловится ничего»:
соседний каталог `community.bak` с поддельным `CHANGELOG.md` не должен мешать
(проверяльщик смотрит только в сборку), а `trap.bak` внутри сборки обязан быть
поймана правилом `excluded`.

## 5. Что исключено из поставки и почему

Исключение без причины — не решение, а забывчивость, поэтому в манифесте у
каждого правила есть текст, а поле `state` отвечает за честность:

* `active` — правило сейчас что-то отсекает (проверка требует `> 0` файлов);
* `guard` — не отсекает ничего, но страхует будущее расширение манифеста
  (проверка требует ровно `0`; если начнёт отсекать — пометка устарела).

Содержательные исключения (сверх служебных каталогов, кешей и резервных копий):

| что | почему |
|---|---|
| `**/.ragraf/**` (18 файлов) | индекс корпуса машино-локален; шаблон ловит и корневой индекс, и подготовленный индекс тестовой фикстуры |
| `**/*.egg-info/**` | метаданные сборки пакета, пересоздаются установкой |
| `docs/AGENTS-context.md` | внутренний материал: адресован агенту этой машины |
| `tests/*_out.txt` (2 файла) | журналы прогонов; в одном лежит выданный токен доступа панели |
Три файла, исключённых на этапе 3, **возвращены в поставку** на этапе 4 по
указанию владельца. Что при этом выяснилось и что сделано:

* `tests/test_db_index.py` действительно содержал значение доступа локальной
  тестовой СУБД — но правило его **не находило** (см. раздел 3, приставка перед
  именем поля). Значение убрано: файл берёт его из `RAGRAF_DB_PASSWORD` или из
  файла доступа этой машины (`catalog/db_access.json`, в поставку не входит), а
  при отсутствии значения печатает причину и возвращает код 2 — «осмотрено ноль
  проверок СУБД», а не «пройдено»;
* `tools/check_admin.py` и `tools/fix_admin_check.py` **настоящего токена не
  содержали вовсе**: оба срабатывания были на заведомо неверном токене в проверке
  отказа («изменение с неверным токеном отклонено», код 403). Убирать такую строку
  значило бы ослабить проверку, поэтому значение объявлено подставным маркером,
  а не вычеркнуто.

Ещё одно расхождение, найденное здесь же: сборка несла **живой** конфиг корпуса
`config/ragraf.yaml` с `root: RAGRAF` (абсолютный путь автора) и теряла
документированный `examples/ragraf.example.yaml`. Конфиг из поставки убран, пример
возвращён — правило `sources` поймало это как расхождение таблицы источников.

Значения в этом документе не печатаются: отчёт читают люди и журналы.

## 6. Детерминизм

Обещать детерминизм бессмысленно — его нужно измерять. Замер 24.10.2026: два
прогона подряд в разные приёмники дали **побайтово одинаковый** `checksums.sha256`
(639 строк, sha256 файла сумм совпал до последнего знака; число изменилось после
возврата трёх файлов и замены живого конфига примером). Значение хэша
не выписано намеренно: оно меняется от любой правки дерева — и это правильно,
детерминизм означает «одно дерево — один хэш», а не «хэш навсегда». Требование
не косметическое: если состав поставки «дрожит» от прогона к прогону, сравнивать
релизы нечем.

Достигается тем, что обход каталогов сортируется, порядок строк в `checksums.sha256`
лексикографический, а перевод строки в файле сумм — LF с завершающим LF (иначе
«детерминизм» был бы детерминизмом одной машины).

## 7. Границы: чего этот конвейер не делает

* не публикует и не коммитит: сеть и git вне его задач;
* не поднимает версию и не ставит тег — это этап 4;
* не правит `.staging/release-v0.2` руками: снимок собирается этим же конвейером
  (сборка вне дерева, затем замена рабочего дерева клона), а хэши шести ключевых
  файлов зафиксированы в `RELEASE-SOURCES.md` и проверяются 24-й проверкой;
* не решает, «полезен ли файл»: он лишь не даёт просочиться тому, что
  исключено, и требует, чтобы исключение было названо причиной.

## 8. Этап 4: что сделано и что осталось

Сделано (измерено 24.10.2026):

1. **Версия объявлена в одном месте** — `src/ragraf/__init__.py` (`__version__`).
   `pyproject.toml` читает её через `dynamic`/`[tool.setuptools.dynamic]`;
   `community/CITATION.cff` повторяет значение, потому что формат CFF требует поля.
   Согласие мест проверяет 25-я проверка (свойство 10), а не обещание.
2. **Снимок пересобран конвейером**: 639 файлов, 8,2 МБ, `checksums.sha256` на 639
   строк; проверяльщик — 8 правил, код 0; гейт — 4 из 4 порчи.
3. **Три файла возвращены в поставку** (раздел 5), правило `secrets` получило
   приставку перед именем поля и объявленные подставные значения.
4. **Живой конфиг корпуса убран из поставки**, документированный пример возвращён.
6. **Навигация сайта и пути автора закрыты (этап 4.5).** `community/mkdocs.yml`
   переписан под фактический состав поставки: 59 целей, все существуют, домашняя
   страница `docs/index.md` добавлена; русские ветви `docs/ru/**` исчезли не молча —
   каталога нет с выпуска 0.2.0, русские оригиналы и есть `docs/02_architecture/**`,
   `docs/03_implementation/**`, `docs/05_article/**`. Итог: `mkdocs build --strict`
   на сборке — код 0 (до правки 23 предупреждения, из них 17 про nav). Пути машины
   автора убраны из 86 файлов поставки; появилось правило `paths` и пятая порча
   гейта.

5. **Появилась карта изданий**: `editions/editions.toml` и
   `tools/release/editions_report.py` доказывают, что у каждого файла живого дерева
   есть место — публикация или объявленное удержание. Это ответ на указание
   владельца «урезанное издание ничего не отменяет»; проверка стала свойством 11
   той же 25-й проверки.

## 6. Второе издание: поставка Pro (ПРОМТ-И15, 25.10.2026)

Конвейер выше описывает ОДНУ поставку. Изданий три, и до 25.10.2026 у Pro не
было ни состава, ни проверки: `editions.toml` объявлял издание, а собрать и
проверить его было нечем.

Что добавлено и чем это доказывается:

| шаг | команда | что доказывает |
|---|---|---|
| состав | `build_release.py --manifest tools/release/manifest-enterprise.toml` | закрытая поставка = Community + объявленная папка `enterprise/` + закрытые модули; манифест наследует правила, а не повторяет их |
| пригодность | `verify_release.py --tree <сборка> --manifest …manifest-enterprise.toml` | те же девять правил применены к закрытой поставке (`правил применено: 9`) |
| гейт | `gate_release.py --manifest …manifest-enterprise.toml` | пять порч пойманы, две приманки отработаны |
| свойства поставки | `check_pro_delivery.py` | два прогона дают одинаковый набор и `checksums.sha256`; нет секретоносных путей и удержанных папок; лицензия открывает объявленное |
| согласие манифестов | `check_release_manifest.py` (свойства 12–14) | наборы правил двух манифестов совпадают по свойству, добавки лежат только в объявленной папке |

Почему наследование, а не второй полный манифест. Копия шестидесяти включений и
двадцати восьми исключений расходится на первой правке, и расхождение не видно
глазами: сборка Pro просто перестала бы совпадать с Community по составу. Своих
исключений у Pro быть не может — это проверяется, а не подразумевается.

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

Осталось за пределами этапа 4 (названо, чтобы не выглядело сделанным):

* PyPI-публикация, сайт `ragraf.ru`, вики — вне конвейера.

## 9. Выпуск 0.4.0 (этап И19, 26.10.2026)

Предмет выпуска: `secret-broker` стал **написанной** возможностью уровня Pro
(`src/ragraf/broker.py`, операции `broker_issue` и `broker_rotate`), а `connectors`
и `fleet` сняты из предмета уровней лицензии и названы в разделе `roadmap`
(`src/ragraf/edition_baseline.json`). Версия 0.4.0 — minor, потому что добавлена
возможность, а не исправление.

Порядок тот же, что в разделе 2, и он необратим: сборка → проверка → публикация →
пересчёт таблицы источников на НОВОЙ голове. Обратный порядок оставил бы таблицу
описанием прошлого.

| шаг | чем | замер 26.10.2026 |
|---|---|---|
| сборка | `build_release.py --out <вне дерева>` | 681 файл, около 8,9 МБ, `checksums.sha256` на 680 строк |
| пригодность | `verify_release.py --tree <сборка>` | 9 правил, осмотрено 5 574 объекта, код 0 |
| гейт | `gate_release.py` | 5 порч из 5 поймано, 2 приманки из 2 отработано |
| граница изданий | проба собранного дерева | `broker.py` и `tests/test_broker.py` отсутствуют, импорт `ragraf.broker` падает, обе операции отвечают отказом (см. ниже) |
| публикация | быстрая отправка в `main`, без `--force` | голова названа в `RELEASE-SOURCES.md`, тег `v0.4.0` — на ней же |
| таблица источников | `sources_table.py --mode post` | 23 строки, все «да» |

Две вещи названы прямо, потому что их легко выдать за сделанное:

* отказ Community на операции брокера приходит как **`EditionRefusal`**, а не
  `FeatureUnavailable`: право проверяется раньше наличия файла. `FeatureUnavailable`
  — второй рубеж, он срабатывает, когда уровень лицензии открыт, а модуля в сборке
  нет. Замерено вызовом на собранном дереве: 18 наблюдений, 0 провалов;
* число файлов от правки существующего файла не меняется, а размер — меняется,
  поэтому размер назван приблизительно, а `checksums.sha256` пересобирается
  каждой сборкой.
