ADR 0106 — Распространение @brabo/runner через tsup и npm publish, версия внедряется только в CI
- Статус: Принят
- Дата: 2026-08-22
- Контекст: backlog ADR 0104
(«распространение runner через
tsup→ единый пакет +npm publish @brabo/runner»), разблокированный ADR 0105 (PAT — «не осталось зависимостей»)
Контекст
apps/runner/package.json был "private": true, с bin, указывающим на
сырой .ts (./src/index.ts), — достижимый только клонированием всего
монорепозитория и упованием на нативный type-stripping Node 24, используемый в
dev. Backlog уже буквально называл выход: tsup, производящий единый
dist/index.cjs, публикуемый как @brabo/runner.
Это ПЕРВЫЙ раз, когда этот репозиторий что-либо публикует во внешний registry,
— подтверждено исчерпывающим grep перед началом: ноль упоминаний NPM_TOKEN,
NODE_AUTH_TOKEN, registry.npmjs.org или provenance во всём коде.
Решение
tsup упаковывает src/index.ts в единый dist/index.cjs
(apps/runner/tsup.config.ts), format: cjs, target: node18 (рантайм,
который обещает engines.node в package.json, — другая ось, чем ES2023 в
tsconfig.json, служащий лишь tsc --noEmit в dev). node-pty — единственный
external: это НАТИВНЫЙ binding (компилируется через node-gyp в postinstall,
тот же binding, что использует VS Code, — pnpm-workspace.yaml уже явно
разрешает его в allowBuilds), и его нельзя встроить в JS-файл — он остаётся
require('node-pty') во время выполнения, разрешаемым через node_modules
того, кто установил пакет. phoenix (чистый JS) встраивается автоматически,
потому что его нет в external, и поэтому переехал из dependencies в
devDependencies — потребителю больше не нужно устанавливать его отдельно. Имя
dist/index.cjs получается бесплатно из format: ['cjs'] + "type": "module"
(уже существовавшего в package.json) — так tsup решает расширение, без нужды в
outExtension. Shebang (#!/usr/bin/env node) и бит исполнения (755)
артефакта обрабатываются tsup автоматически: он обнаруживает shebang во входе
и воспроизводит в выводе.
Настоящая находка: охрана автозапуска была сломана для случая, который эта волна существует, чтобы сделать возможным
apps/runner/src/index.ts решал, запускать ли main(), сравнивая
process.argv[1]?.endsWith('index.ts') || .endsWith('brabo-runner') — проверка
по СУФФИКСУ ИМЕНИ ФАЙЛА, хрупкая к любому переименованию при упаковке.
Очевидное исправление (import.meta.url === pathToFileURL(process.argv[1]).href,
стандартная идиома ESM для «модуль входа — это я?») было эмпирически
ПРОТЕСТИРОВАНО с реальным процессом Node и symlink, прежде чем войти в код, — и
оно тоже сломано, по причине, противоположной ожидаемой:
process.argv[1] никогда не разрешается Node через realpath — это
буквальный путь, которым ОС воспользовалась для вызова, лишь сделанный
абсолютным. import.meta.url (и shim, который tsup генерирует для
import.meta.url в cjs-сборке, на основе __filename) всегда разрешается
через realpath загрузчиком модулей. npm install -g создаёт ровно эту
асимметрию: symlink в node_modules/.bin/brabo-runner, указывающий на
настоящий dist/index.cjs внутри node_modules/@brabo/runner/. Запуск CLI
через установленный bin — ОСНОВНОЙ ПУТЬ, ради которого существует вся эта
волна, — давал бы false в сравнении всегда, и main() никогда бы не
вызывался. Молчаливый баг: никакой smoke, запускающий только node dist/index.cjs напрямую (минуя symlink), его бы не поймал.
Окончательное исправление применяет realpathSync к process.argv[1] перед
сравнением:
import { realpathSync } from 'node:fs';
import { pathToFileURL } from 'node:url';
const invocadoDiretamente =
!!process.argv[1] &&
import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href;
Проверено (с реальным процессом Node) в четырёх случаях: dev
(node src/index.ts), прямой node dist/index.cjs, через symlink (случай,
который был сломан) и никогда не срабатывает, когда importируется тестом. В
Windows shim .cmd/.ps1 от npm уже вызывает node <реальный путь> без
какого-либо symlink — realpathSync там становится no-op, без регрессии.
Версия: внедряется только в CI, никогда не коммитится
Версия всего репозитория вычисляется и живёт только в git-теге (vX.Y.Z,
Фаза 6) — никогда в package.json. npm publish, однако, ТРЕБУЕТ, чтобы
package.json нёс публикуемую версию. Выход: npm pkg set version=<версия-тега>
выполняется внутри ОДНОРАЗОВОГО checkout workflow публикации, никогда не
коммитится обратно в репозиторий — та же философия «тег — единственный
источник истины», лишь удовлетворяющая механическому требованию самого
npm publish.
latest в npm может откатиться назад — смягчено до того, как это случилось
У этого репозитория уже шесть осиротевших финальных тегов (v0.2.0,
v0.3.0, v0.3.1, v1.0.0, v1.0.1, v1.1.0 — никогда не
опубликованных, задокументировано в docs/reference/rulesets.md), и
workflow_dispatch ручной переопубликации существует именно для починки такого
рода дыр (тот же образец, что release.yml). npm publish по умолчанию
переносит dist-tag latest на только что опубликованное, НЕ проверяя порядок
semver — переопубликация старого осиротевшего тега после того, как уже
опубликована более новая версия, молча откатила бы latest назад, и
npm install -g @brabo/runner начал бы устанавливать старый код.
publish-runner.yml сравнивает целевую версию с
npm view @brabo/runner version перед публикацией: если она >= текущего
latest, публикует нормально; иначе публикует с собственным dist-tag
(--tag antiga-<versão>), никогда не трогая latest.
Собственный workflow, а не лишний шаг в release.yml
publish-runner.yml запускается ТЕМ ЖЕ событием, что release.yml
(push: tags: 'v[0-9]+.[0-9]+.[0-9]+'), но это отдельный файл — та же
дисциплина, уже зафиксированная в ADR 0030 для tag-release.yml:
«СОБСТВЕННЫЙ workflow, запускаемый тегом... здесь нечего переподключать».
Публикация Docker-образа и публикация npm-пакета — два независимых продукта
одного события, каждый со своим способом падать; сворачивание этого внутрь
release.yml заставило бы отказ npm ронять (или маскировать) GitHub Release,
или наоборот. Оба выполняются ПАРАЛЛЕЛЬНО, без needs: — реальной
зависимости между ними нет.
Без одного лишь NODE_AUTH_TOKEN. actions/setup-node пишет ~/.npmrc,
благодаря которому NODE_AUTH_TOKEN реально аутентифицирует, только когда шаг
получает registry-url (и scope), — ни одному workflow этого репозитория это
никогда не было нужно, так что ни один этого уже не делал. Без настроенного
секрета NPM_TOKEN workflow ПРЕДУПРЕЖДАЕТ (::warning::) и ПРОПУСКАЕТ —
никогда не падает, — тот же образец TEM_PAT, уже используемый в
tag-release.yml/release.yml (secrets.* нельзя прямо в if:).
Закрытие дыры в покрытии в ci.yml
apps/runner никогда не запускал ни одного теста в job Testes TS (api + web)
(подтверждено чтением всего файла) — предсуществующая дыра, ставшая серьёзнее
теперь, когда этот код распространяется третьим лицам. Четыре новых шага там
(test, typecheck, build, smoke), без переименования job — имя job
является идентичностью обязательного check в GitHub
(docs/reference/rulesets.md явно это документирует), и его переименование
стёрло бы обязательный check, который больше никогда не запустился бы.
apps/runner/scripts/smoke-dist.mjs — единственный тест, проверяющий
УПАКОВАННЫЙ АРТЕФАКТ от начала до конца, — модульные тесты лишь вызывают
экспортированные функции из src/, никогда опубликованный dist/index.cjs.
Он проверяет существование + бит исполнения и запускает бинарник ДВУМЯ
способами: прямой exec (по-настоящему проверяет shebang — один лишь node dist/index.cjs НИКОГДА бы этого не проверил) и через явный node dist/index.cjs. Побочный эффект, который стоит зафиксировать: поскольку
pty.ts импортирует node-pty в начале модуля (поднятым до любого разбора
аргументов), этот smoke также доказывает, что нативный binding разрешается и
загружается в окружении CI, — это не только тест упаковки, это реальный smoke
нативного addon. Переиспользуется ci.yml (каждый PR, чтобы поймать регрессию
упаковки ДО merge) и publish-runner.yml (прямо перед публикацией, ремень и
подтяжки — никогда не доверять сборке старого PR).
Последствия
- Разблокирует
npm install -g @brabo/runnerкак реальный путь распространения — пункт, закрывающий последний хвост backlog ADR 0104. - Операционный хвост, вне досягаемости этого PR: владелец продукта должен
создать/подтвердить scope
@braboна npmjs.com, сгенерировать npm Automation Token и настроить секретNPM_TOKENв репозитории. До тех порpublish-runner.ymlвыполняется и предупреждает, не публикуя по-настоящему и не проваливая workflow, — тот же образец, что отсутствующийBRABO_BOT_TOKENвrelease.yml. - Ветка поставки —
breaking/, а неfeature/, — потому что реальная публикация требует этого действия оператора, прежде чем заработает от начала до конца, тот же образец, что социальный вход (ADR 0084), и урок, уже зафиксированный в CLAUDE.md, об обязательном секрете OAuth, который родился (ошибочно) вbugfix/. Это поднимает MAJOR версию всего репозитория (политика — на весь репозиторий, а не по workspace) — последствие, принятое осознанно. - Без новой RN в
docs/business-rules.md— это инфраструктура распространения, а не бизнес-правило продукта. Без новой записи вdocs/gates.yml— это не gate продуктового решения, это механизм CI. Оба отсутствия — объявленное решение. docs/reference/rulesets.mdне меняется — таблица «Обязательные checks» ограничена checks workflow, запускаемыхpull_request, аpublish-runner.ymlзапускается только на push финального тега/workflow_dispatch.- Вне рамок, по объявленному решению: автономный бинарник
(
pkg/bun build --compile, отдельный пункт backlog, большая цена, без определённого триггера); исключительность runner по{project_id, machine_id}(отложена); отзыв PAT другого пользователяmaintainer(вне рамок со времён ADR 0105);guard.tsbest-effort (подтверждённый инвариант, не пробел).