# Сборка, CI и релиз

> Раскладка репозитория — в module-structure.md, процесс —
в CONTRIBUTING.md, установка глазами пользователя —
в README.md.

Раскладка репозитория — в [module-structure.md](/modules/options/module-structure), процесс —
в [CONTRIBUTING.md](https://github.com/bx-shef/options/blob/main/CONTRIBUTING.md), установка глазами пользователя —
в [README.md](/modules/options).

## `build.sh` — единственная точка входа сборки

```bash
./build.sh            # проверки + архив shef.options.zip
./build.sh --check    # только проверки
./build.sh --version  # напечатать версию модуля
./build.sh --notes    # примечания к релизу из CHANGELOG
./build.sh --notes 3.0.6   # то же, но отсчёт от указанного выпуска
```

CI зовёт **её же**. Это не украшение: если бы сервер гонял свой набор команд,
локальный зелёный прогон и серверный красный означали бы разные вещи, и
разбираться пришлось бы в двух местах сразу.

## Линтер — рядом, а не внутри

```bash
composer install           # поднять инструменты разработчика
composer run lint          # сухой прогон: покажет диф и упадёт
composer run lint:fix      # привести файлы
```

php-cs-fixer, правила в `.php-cs-fixer.dist.php`, набор — `@PSR12` целиком.

Из `build.sh` он НЕ зовётся, и это решение, а не недоделка: сборке хватает
`php`, `git` и `zip`, и она обязана отрабатывать в свежем клоне. Позови она
линтер — `./build.sh --check` перестал бы запускаться, пока не сделан
`composer install`, то есть проверка поставки начала бы зависеть от сети.
Поэтому проверки две, и обе обязательны в CI.

Версия инструмента в `composer.json` пришпилена **точно**, без `^`: набор
`@PSR12` у php-cs-fixer пополняется в минорных выпусках, и с `^3.0` CI однажды
покраснел бы на коммите, который ничего не менял. Обновление — осознанная
правка одной строки, следом `composer update` и новый `composer.lock`.

`composer.lock` под контролем git — в отличие от обычая для библиотек, и
намеренно. Точная версия пришпиливает сам php-cs-fixer, но не три десятка его
зависимостей: без lock CI разрешал бы их заново на каждом прогоне. На
зависимости пакета это не влияет — Composer читает lock только корневого
проекта, а в поставку файл не едет (KEEP плюс `export-ignore`).

`config.platform.php` = `8.2.0` — тоже не украшение. Без него Composer
разрешает зависимости под тот PHP, на котором запущен, и lock, собранный на
8.4, на нижней границе поддержки не ставится: измерено на CI —
`symfony/string v8.1.7` и `sebastian/diff 9.0.1` требуют 8.4. С пином lock
годится для всех версий из матрицы. На потребителей пакета это не влияет:
`config` Composer читает только у корневого проекта.

`config.allow-plugins` в `composer.json` — не украшение: `composer/installers`
это плагин, а Composer с 2.2 по умолчанию блокирует плагины и в неинтерактивном
режиме просто падает. Без этого ключа задача `Lint` не доходила даже до
линтера.

Чего линтер не ловит: отступы в инлайновом HTML. Строки между `?>` и `<?php`
для него текст, а не код. См. CLAUDE.md, «Линтер не видит отступы в инлайновом
HTML».

## Что проверяется

| проверка | что ловит |
|---|---|
| `check_filenames` | имя, с которым не справится скрипт; символическую ссылку под контролем git |
| `check_lists` | файл, не попавший ни в SHIP, ни в KEEP |
| `check_gitattributes` | расхождение KEEP и `export-ignore` — в обе стороны |
| `check_encoding` | файл не в UTF-8, BOM в начале файла |
| `check_php` | `php -l` по всем PHP |
| `check_short_tags` | короткий тег `<?` |
| `check_js` | `node --check` по всем JS |
| `check_lowercase` | заглавные буквы в путях `lib/` |
| `check_changelog_section` | в `CHANGELOG.md` нет секции текущей версии |
| `check_version` | пустой или кривой `VERSION`, пустой `VERSION_DATE` |
| `run_tests` | `tests/*_test.php` (php) и `tests/*_test.mjs` (node) |
| `check_composer_package` | состав `git archive` разошёлся со списком SHIP |

При сборке дополнительно: состав zip сверяется со списком SHIP, а первый
уровень внутри архива — с `shef.options/`.

### Что здесь сделано «строже, чем хотелось»

**Пропущенная проверка выглядит как пройденная.** Поэтому:

* нет `node`, а JS или `*_test.mjs` в репозитории есть — отказ, а не примечание;
* `run_tests` сначала считает, сколько тестов ЕСТЬ, и сверяет с числом
  прогнанных: переименованный файл или тест в подкаталоге иначе выпал бы из
  прогона молча;
* `check_composer_package` сверяет **индекс** (`git write-tree`), а не `HEAD`, и
  потому работает и на грязном дереве. Раньше он на ней пропускался — а локально
  дерево грязное почти всегда, так что единственная проверка, ловящая
  расхождения самого `git archive`, срабатывала только в CI.

**Символическая ссылка разводит каналы поставки молча.** `cp` в архив
разыменовывает её и кладёт содержимое цели, `git archive` кладёт саму ссылку, а
обе сверки состава при этом зелёные — они сверяют имена. Поэтому ссылок под
контролем git просто не бывает: `check_filenames` роняет сборку.

### Про короткие теги отдельно

`php -l` их **не ловит**. При `short_open_tag=Off` — а это значение по
умолчанию — `<?` открывающим тегом не считается, и файл целиком становится
инлайновым HTML. Синтаксически он остаётся правильным, в нём просто нет PHP.
А на портале классы из такого файла не определяются, зато исходник уезжает в
браузер.

Поэтому `check_short_tags` спрашивает сам PHP через `token_get_all()`, а не
grep: `<?` внутри строки или комментария лежит в своём токене, опасный же
остаётся куском `T_INLINE_HTML`. Наивный grep краснел бы на каждом регулярном
выражении вида `/<?/`.

## Архив

Архив содержит **каталог модуля целиком**: первым уровнем внутри zip лежит
`shef.options/`, иначе при распаковке файлы рассыплются прямо по
`bitrix/modules/`. Проверяется в самом скрипте, а не глазами.

Сверить поставку двумя путями установки можно так:

```bash
git archive --format=tar "$(git write-tree)" | tar -tf - | grep -v '/$' | sort > /tmp/composer.txt
./build.sh && unzip -Z1 shef.options.zip | grep -v '/$' | sed 's#^shef.options/##' | sort > /tmp/zip.txt
diff /tmp/composer.txt /tmp/zip.txt    # должно быть пусто
```

## CI

`.github/workflows/ci.yml`, четыре задачи:

| задача | что делает |
|---|---|
| `PHP 8.2` … `PHP 8.5` | `./build.sh --check`, `fail-fast: false` |
| `Build` | `./build.sh` плюс архив артефактом прогона |
| `Lint` | `composer install` и `composer run lint`, одна версия PHP |
| `CI` | ворота, `needs: [checks, build, lint]` |

`Lint` гоняется на одной версии PHP, а не на матрице: форматирование от версии
рантайма не зависит, а четыре одинаковых прогона только тянули бы время.

В защите ветки требуется ровно одна проверка — `CI`. Остальные её зависимости,
поэтому новая задача не потребует правки ruleset.

### Что в `ci.yml` выглядит ошибкой, но ею не является

**`if: always()` у задачи `CI`** — обязателен вместе с явной сверкой результатов
зависимостей. Без него задача была бы *пропущена* при падении зависимости, а
пропущенную проверку защита ветки засчитывает как *пройденную*: красный CI уехал
бы в `main`.

Подмывает заменить на `!cancelled()` — не надо. Тогда отменённый прогон стал бы
давать пропущенную проверку, и, отменив прогон вручную, можно было бы смержить
непроверенное.

**Вытесненный по `concurrency` прогон краснеет** на устаревшем коммите. Это шум,
а не поломка: защита смотрит на проверки головного коммита.

## Релиз

`.github/workflows/release.yml`, два входа.

**Пуш тега `v*`** — тег **сверяется** с `VERSION` из `install/version.php`.
Расхождение роняет прогон: тегу не доверяем, иначе на портал уедет архив,
версия которого врёт.

**`workflow_dispatch` от `main`** — тег **выводится** из `VERSION` и ставится
сам. Запуск от другой ветки отклоняется, занятый тег ловится до сборки.

Второй вход обязателен: пуш тегов бывает недоступен — другие права, прокси
сессии, — а релиз выпускать надо.

**Тег ставится после успешной сборки.** Поставленный раньше, он пережил бы
упавшую сборку, и следующая попытка упёрлась бы в занятый тег.

**Примечания к релизу** собирает `./build.sh --notes` — секции `CHANGELOG.md`
от текущей версии до предыдущего **выпущенного** тега, не включая его.

Раньше бралась одна секция текущей версии, и это молча теряло всё, что слили
в `main`, но не выпустили. Между `v3.0.6` и `v3.0.14` так накопилось семь
секций: на странице релиза стоял один линтер, а в архиве лежали ещё и починка
`toArray()`, `\Stringable` и снятие `_log1()`.

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

Предыдущий выпуск ищется по тегам: самый старший `vX.Y.Z`, который **строго
младше** текущей версии и **достижим из `HEAD`**. Оба условия про одно и то
же — не начать отсчёт от версии, секции которой в этом `CHANGELOG.md` нет.
Тег выше текущей версии так и выглядит; тег на ветке поддержки или
поставленный руками мимо `main` — тоже, только снизу. Сравниваются числа по
трём частям, а не строки: строкой «3.0.9» больше «3.0.14», а `git` и вовсе
печатает `v3.0.14` раньше `v3.0.2`.

### Когда граница не определилась

Три случая, и все три ведут себя одинаково: примечания идут **до конца
файла**, а строка «Версии ниже отдельными релизами не выпускались» **не
печатается** — под ней оказались бы выпущенные версии, и страница релиза
утверждала бы неправду ровно тогда, когда что-то пошло не так.

| что случилось | что в stderr |
|---|---|
| тегов `v*` нет вовсе — первый выпуск или клон без тегов | «Предыдущих тегов нет» |
| секции предыдущего выпуска в `CHANGELOG.md` нет | «нет секции X» |
| секция предыдущего выпуска стоит не ниже текущей | «стоит не ниже» |

Третий случай — это и `./build.sh --notes <текущая версия>`: спутать легко, а
молча уехала бы вся история модуля.

Обратного случая — секции **текущей** версии нет, примечания пустые — ждать
не надо: его ловит `check_changelog_section` в `./build.sh --check`, то есть
в PR, а не в момент выпуска.

Логика лежит в `build.sh`, а не в теле workflow, по одной причине: то же
самое получается локально одной командой, и на неё написан тест
(`tests/release_notes_test.php`). Код в yaml не проверяется ничем, кроме
выпуска релиза.

Выбранный предыдущий выпуск печатается в stderr — в журнале релиза видно, от
чего шёл отсчёт. Локально перед прогоном нужны свежие теги: в клоне, где их
нет совсем, в примечания уйдёт весь `CHANGELOG.md`, а где они протухли —
отсчёт пойдёт от последнего известного, и примечаний окажется больше, чем
надо.

```bash
git fetch --tags origin && ./build.sh --notes
```

**Собрать примечания к уже выпущенному релизу** — тем же режимом, аргументом.
Так восстанавливается текст для выпуска, который вышел с неполными
примечаниями:

```bash
mkdir -p /tmp/notes/install && cp build.sh /tmp/notes/
git show v3.0.14:CHANGELOG.md      > /tmp/notes/CHANGELOG.md
git show v3.0.14:install/version.php > /tmp/notes/install/version.php
/tmp/notes/build.sh --notes 3.0.6
```

Пустые примечания — не ошибка: workflow подставит «Версия X. Изменения — в
CHANGELOG.md». Ненулевой код возврата уронил бы выпуск из-за оформления
`CHANGELOG.md`.

### Packagist

Последним шагом релиз дёргает `update-package`. Без секретов
`PACKAGIST_USERNAME` и `PACKAGIST_TOKEN` шаг пропускается, и релиз при этом
**не падает**: невыложенный релиз чинить нечем, а отставший Packagist
догоняется кнопкой Update за десять секунд.

Эндпойнт умеет только **обновлять уже зарегистрированный** пакет. Первую
регистрацию делают один раз руками: packagist.org → Submit →
`https://github.com/bx-shef/options`.

## Куда Composer кладёт модуль

`composer.json`: `type` = `bitrix-module` плюс
`extra.installer-name = shef.options`. Тогда Composer разворачивает модуль в
`bitrix/modules/shef.options/` без настройки на стороне потребителя:
`installer-name` читается из пакета, а `{$bitrix_dir}` — только из корневого
`composer.json`, повлиять на него пакет не может.

**`bitrix-d7-module` развернул бы модуль не туда.** Шаблоны в
`composer/installers`:

* `bitrix-module` → `{$bitrix_dir}/modules/{$name}/`
* `bitrix-d7-module` → `{$bitrix_dir}/modules/{$vendor}.{$name}/`

а `installer-name` подменяет только `{$name}`. Для пакета `bxshef/options`
второй вариант дал бы `bitrix/modules/bxshef.shef.options/` — каталог, которого
Битрикс не знает.

На стороне проекта-потребителя Composer 2.2+ требует явного разрешения
плагина, иначе в неинтерактивном режиме (CI) он не отработает и пакет ляжет в
`vendor/bxshef/options`:

```json
{
	"config": {
		"allow-plugins": {
			"composer/installers": true
		}
	}
}
```

`bitrix-module` помечен в исходниках `composer/installers` как `deprecated,
remove on the major release`, поэтому в `require` стоит потолок
`"composer/installers": "^1.0 || ^2.0"`. Снимут потолок — модуль уедет в чужой
каталог.

## Проверка на портале

Каталог модуля браузеру недоступен: в поставке nginx стоит `deny all` на
`^/bitrix/(modules|local_cache|stack_cache|managed_cache|php_interface)`.
Поэтому фронт и раскладывается в `/bitrix/css` и `/bitrix/js`.

Проверить на стенде:

```
/bitrix/modules/shef.options/js/...        -> 403
/bitrix/css/shef.options/admin-options.css -> 200
```

Полная процедура проверки на портале — в [portal-check.md](/modules/options/portal-check):
десять шагов с ожидаемым результатом, отдельно обновление с 2.x и запуск
примеров на живом ядре. Тестами рантайм Битрикса не покрыть, поэтому эта
процедура и есть тест.

::note
Источник: [options/docs/build-and-install.md](https://github.com/bx-shef/options/blob/main/docs/build-and-install.md) — правки туда, сайт пересобирается сам.
::
