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

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

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

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

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

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

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

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_phpphp -l по всем PHP
check_short_tagsкороткий тег <?
check_jsnode --check по всем JS
check_lowercaseзаглавные буквы в путях lib/
check_changelog_sectionв CHANGELOG.md нет секции текущей версии
check_versionпустой или кривой VERSION, пустой VERSION_DATE
run_teststests/*_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/. Проверяется в самом скрипте, а не глазами.

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

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 плюс архив артефактом прогона
Lintcomposer 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, а где они протухли — отсчёт пойдёт от последнего известного, и примечаний окажется больше, чем надо.

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

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

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:

{
    "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: десять шагов с ожидаемым результатом, отдельно обновление с 2.x и запуск примеров на живом ядре. Тестами рантайм Битрикса не покрыть, поэтому эта процедура и есть тест.

Источник: options/docs/build-and-install.md — правки туда, сайт пересобирается сам.
CtrlI