Перейти к основному содержимому

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.ts best-effort (подтверждённый инвариант, не пробел).