open atlas
↑ К треку
Node.js с нуля до senior NODE · 08 · 05

Релизный конвейер: версии, dist-tags и npm provenance

Релиз — это тарбол плюс контракт. Поле `files` и dry-run решают, что уедет; semver и dist-tags — кто это разрешит; provenance и узкие CI-токены — кто это собрал. Ошибки почти необратимы: после 72ч unpublish невозможен, поэтому помечаешь deprecated и выкатываешь фикс.

NODE Senior ◷ 18 min
Уровень
ОсновыJuniorMiddleSenior

Ты выкатил 2.1.0 в пятницу, CI был зелёный, npm install отчитался успехом у каждого потребителя — а в понедельник в баг-репортах стояло Cannot find module 'your-lib'. Пакет на реестре весил 2 КБ и содержал только package.json и README: твой dist/ так и не опубликовался. Ты вынес сборку в шаг prepublishOnly, но добавил allow-list "files": ["lib"], пока сборка эмитила в dist/, так что тарбол идеально совпал с allow-list — пустым совпадением. Установки «прошли», потому что npm бодро распаковывает пустой тарбол. Сделать unpublish ты не смог: 200 загрузок за 12 часов означали, что 72-часовое окно без зависимых уже ушло. Единственным выходом было выкатить 2.1.1 и пометить npm deprecate сломанную версию. Один npm publish --dry-run показал бы, что в тарболе ноль .js-файлов, ещё до того, как что-либо доехало до реестра.

Тарбол — это не твой репозиторий: содержимое решает поле files

Когда ты в последний раз запускал npm publish --dry-run перед настоящей публикацией? Если ответ «никогда» или «забыл» — ты публиковал на веру, а реестр не имеет кнопки редактирования.

npm publish не загружает твою директорию; он пакует тарбол и грузит его. Что туда попадёт, регулируется точным и неожиданным набором правил, и ошибка здесь — самый частый способ выкатить сломанный или опасный релиз. Множество включаемого — это allow-list "files" в package.json, если он есть; иначе всё, кроме того, что исключает .npmignore, а если .npmignore нет, npm откатывается на .gitignore. Ловушка: .gitignore — это не .npmignore. Как только ты добавляешь .npmignore (хоть ради исключения одной вещи), он полностью замещает .gitignore, и файлы, которые, как ты думал, прячет git — .env, фикстуры, внутренние доки — снова в тарболе. Несколько имён включаются всегда при любом раскладе (package.json, README, LICENSE, точка входа main/bin), а несколько всегда исключаются (node_modules, .git).

Есть два противоположных режима отказа, оба уезжают в публичный реестр, который нельзя отредактировать постфактум.

// package.json — allow-list это безопасный дефолт: уезжает ТОЛЬКО то, что ты имел в виду
{
  "name": "your-lib",
  "version": "2.1.0",
  "files": ["dist"],          // ← allow-list. НЕ "lib", если собираешь в dist/
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "scripts": {
    "build": "tsc -p tsconfig.build.json",
    "prepublishOnly": "npm run build"   // сборка запускается автоматически перед publish
  }
}

Слишком узко — это хук: "files": ["lib"], когда ты собираешь в dist/, отправляет тарбол без рантайм-кода. Каждая установка успешна, и каждый require("your-lib") кидает MODULE_NOT_FOUND, потому что точка входа из main указывает на файл, которого нет в тарболе. Слишком широко — хуже и необратимо: нет поля files, протухший .npmignore или маска, заметающая .env, .npmrc с auth-токеном, фикстуры с реальными клиентскими строками или сорс-мапы, в которые встроен весь твой исходник. Секрет, опубликованный в публичный реестр, неотзываем — даже после unpublish или deprecate любой, кто стянул тарбол (и собственные зеркала npm, и десятки прокси-реестров, кэширующих его), имеет его навсегда. Единственный верный ответ на утёкший креденшл — ротировать его, немедленно, ещё до того, как трогать пакет.

Дисциплина, предотвращающая оба исхода, — одна команда перед каждым релизом: осмотреть точный тарбол.

$ npm publish --dry-run        # пакует и отчитывается, НИЧЕГО не грузит
npm notice 📦  your-lib@2.1.0
npm notice === Tarball Contents ===
npm notice 1.2kB  package.json
npm notice 8.4kB  dist/index.js
npm notice 2.1kB  dist/index.d.ts
npm notice 1.0kB  README.md
npm notice === Tarball Details ===
npm notice package size:  4.8 kB
npm notice unpacked size: 12.7 kB
npm notice total files:   4

Ты проверяешь три вещи за пять секунд: файл точки входа из main присутствует (ловит баг пустого пакета), ничего секретного или негабаритного не появилось (ловит утечку — внезапный скачок с 5 КБ до 4 МБ значит, что ты упаковал node_modules или фикстуры), и счётчик total files правдоподобен. npm pack --dry-run делает то же без прав на публикацию. Это бесплатно и это самая высокорычажная привычка во всём уроке, потому что всё после publish откатить трудно или невозможно.

Semver и dist-tags: кто на самом деле разрешает твою версию

Голый npm install your-lib не ставит наивысшую версию на реестре — он ставит ту версию, на которую сейчас указывает dist-tag latest. Dist-tag — это именованный изменяемый указатель (latest, next, beta, legacy…) на конкретную неизменяемую версию; latest — просто тот, что npm берёт по умолчанию. Эта косвенность и есть механизм безопасных раскаток. Когда ты делаешь npm publish, новая версия становится latest, если ты не скажешь иначе — и этот дефолт второй по частоте футган после files, ведь публикация предрелиза без тега тихо делает его дефолтной установкой для всех.

# опубликовать предрелиз БЕЗ сдвига дефолта для всех
$ npm publish --tag next          # 3.0.0-beta.1 → тег "next", latest не трогаем
# ранние адоптеры включаются явно:
$ npm install your-lib@next

# выкатить фикс в СТАРЫЙ мажор, пока актуален v3 — дай ему свой тег
$ npm publish --tag legacy        # 2.5.4 → "legacy", не становится latest

# повысить бету до настоящего релиза, когда она проверена, БЕЗ переопубликации:
$ npm dist-tag add your-lib@3.0.0 latest

Глубже лежит контракт самого semver. Потребители пинят диапазоны, в подавляющем большинстве ^ (caret): "^2.1.0" значит «любая 2.x на уровне 2.1.0 или выше». Этот диапазон — обещание, которое ты даёшь о своих номерах версий: patch и minor обратно совместимы, major вправе ломать. Нарушишь его — и режим отказа жесток и тих: опубликуй ломающее изменение как patch (2.1.4 → 2.1.5), и каждый потребитель с диапазоном ^ стянет его при следующем npm install или прогоне CI, без opt-in и без предупреждения, и их сборка сломается в PR, не трогавшем твою библиотеку. Именно так «крошечный фикс» кладёт сотни нижестоящих CI-конвейеров за полдня. Поскольку цену неверного номера версии несут все ниже по потоку, senior-команды перестают бампать руками: инструменты вроде changesets или semantic-release читают твои conventional commits (fix: → patch, feat: → minor, feat!:/BREAKING CHANGE → major) и вычисляют версию, чейнджлог и тег механически, чтобы версия отражала реальное изменение, а не догадку уставшего человека в 17:00.

Выбери лучший вариант

Актуален v3.0.0 и `latest`. Ты нашёл фикс безопасности, который надо доставить пользователям, оставшимся на линии v2 и пинящим `^2.0.0`. Как выкатить так, чтобы v2-пользователи его получили и никто не прыгнул через мажор?

Provenance и токены: доказать, кто собрал тарбол

Реестр доверяет тому, кто держит валидный токен публикации. Это и есть вся поверхность атаки современной цепочки поставок со стороны публикатора: один утёкший долгоживущий токен публикации позволяет атакующему сделать npm publish вредоносного патча (2.1.4 → 2.1.5), который каждый потребитель с диапазоном ^ автоустановит при следующей сборке — без компрометации твоего репозитория исходников, без ревью, без следа. Это не теория; это форма реальных инцидентов, когда популярные пакеты внезапно выкатывали кражу креденшелов. Защиты слоисты, и senior-конвейер использует их все.

Первое — гигиена токенов. Никогда не публикуй из персонального токена доступа, лежащего на ноутбуке или в CI-секрете бессрочно. Используй гранулярный automation-токен: ограниченный одним пакетом, без более широкого доступа к аккаунту, в идеале с коротким TTL и --read-and-write только для того, что ему нужно. Требуй 2FA на аккаунте, а для шага публикации предпочитай OIDC, чтобы статического секрета, который можно утянуть, не было вовсе. Радиус поражения украденного узкого токена — один пакет; радиус персонального токена — всё, что ты можешь публиковать.

Второе — provenance. Публикация с --provenance из поддерживаемого CI-раннера (GitHub Actions, GitLab) заставляет npm сгенерировать подписанную проверяемую аттестацию, которая криптографически связывает опубликованный тарбол с точным коммитом исходника и собравшей его сборкой, и записывается в публичный transparency-лог. Потребители (и npm audit signatures) могут затем проверить, что данная версия и впрямь пришла из заявленных репозитория и workflow — так тарбол, опубликованный из украденного токена вне твоего CI, проваливает проверку, а зелёный значок «provenance» на npm — проверяемое утверждение, а не логотип. Это не требует статического секрета публикации, потому что едет на OIDC-идентичности CI-раннера.

# .github/workflows/release.yml — публикация с provenance, без статического npm-токена
permissions:
  contents: read
  id-token: write          # ← ОБЯЗАТЕЛЬНО: даёт раннеру выпустить OIDC-токен для provenance
jobs:
  publish:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          registry-url: https://registry.npmjs.org
      - run: npm ci
      - run: npm publish --provenance --access public
        # NODE_AUTH_TOKEN не нужен, когда пакет + репо настроены на
        # trusted publishing (OIDC); иначе фолбэк — УЗКИЙ automation-токен,
        # никогда не персональный.

Режим отказа, который это закрывает, точен: даже если твой токен публикации утёк, у бэкдор-версии, опубликованной с машины атакующего, нет валидного provenance для твоего репозитория, так что проверяющие потребители и UI npm помечают её как недоверенную — превращая тихую компрометацию цепочки поставок в видимую и отклоняемую. Provenance не мешает утёкшему токену опубликоваться; он делает результат обнаружимым, и это реалистичный выигрыш.

Почему это работает

Почему нельзя просто удалить плохой релиз? В марте 2016 автор left-pad — функции на 11 строк — снял её с npm в споре. Тысячи пакетов зависели от неё транзитивно (включая Babel и инструментарий React), и CI и деплои сломались по всей индустрии за минуты, потому что опубликованная-и-зависимая версия просто исчезла. npm восстановил её и затем навсегда изменил правила: ты можешь сделать npm unpublish конкретной версии только в течение 72 часов с момента публикации, и только если ни один другой пакет от неё не зависит и её не качал никто, кроме тебя. После этого окна, или как только что-то от неё зависит, версия фактически неизменяема. Вся экосистема npm теперь предполагает, что установленные версии никогда не исчезают — именно поэтому важны dry-run и provenance: забрать ошибку назад почти никогда не получится.

Deprecate против unpublish: как ты на самом деле отзываешь

Поскольку unpublish огорожен правилом 72 часов, настоящий инструмент отзыва — npm deprecate. Он ничего не удаляет — версия остаётся полностью устанавливаемой — но прикрепляет предупреждение, которое npm печатает всякий раз, когда кто-то ставит эту версию (напрямую или транзитивно). Это ровно то, что нужно для «в этом релизе серьёзный баг, перестаньте его использовать» без насилия по выдиранию кода, который рабочие установки всё ещё могут пинить.

# плохой релиз остаётся устанавливаемым, но каждая установка теперь предупреждает
$ npm deprecate your-lib@2.1.0 "Пустой пакет — сломанная сборка. Обновитесь до >=2.1.1."

# можно пометить целый диапазон, например известный уязвимый промежуток:
$ npm deprecate your-lib@"<2.1.1" "Фикс безопасности в 2.1.1, пожалуйста обновитесь."

# отменить deprecation, пометив пустым сообщением:
$ npm deprecate your-lib@2.1.0 ""

Так что каноническое восстановление после плохого релиза — не «удалить его», а опубликовать исправленную версию, затем пометить deprecated плохую, указав на фикс. Цифры делают ставки конкретными: утёкший секрет неотзываем в момент, когда тарбол стянут; пустой или сломанный пакет нельзя удалить после 72 часов или единственной внешней загрузки; и единственные правки после публикации — это предупреждение deprecation и сдвинутый dist-tag. Всё выше по течению от npm publish — allow-list files, dry-run, вычисленный semver, узкий токен, аттестация provenance — существует именно потому, что реестр для всех практических целей доступен только на дозапись.

Викторина

Ты опубликовал `your-lib@4.2.0` два дня назад. В ней критический баг, её скачали 300 раз, и теперь от неё зависят три пакета. Как правильно её отозвать?

Вспомните перед уходом
  1. 01
    Почему `npm install your-lib` иногда ставит версию старше наивысшей опубликованной, и как выкатить предрелиз, не нарушив этого?
  2. 02
    Токен публикации твоего пакета утёк. Что может сделать атакующий, и как provenance меняет исход, хоть и не может остановить публикацию?
Итог

Релиз — это три отделяемых контракта, и каждый из них решается до npm publish, потому что после него почти ничего необратимо. Что уезжает — это тарбол, а не твой репозиторий: allow-list "files" (или .npmignore, который полностью замещает .gitignore, как только появляется) решает содержимое — слишком узко отправляет пустой пакет, чей require падает у каждого потребителя, слишком широко отправляет .env или сорс-мапы в публичный реестр, который их никогда не отредактирует, так что утёкший секрет надо ротировать, а не делать unpublish. Фикс — это --dry-run перед каждым релизом, чтобы прочитать точный тарбол, плюс сборка в prepublishOnly за allow-list. Кто это разрешит задаётся semver и dist-tags: npm install следует за указателем latest, не за наивысшей версией, так что предрелизы идут под --tag next, а фиксы старого мажора — под своим тегом, и ломающие изменения никогда не должны ехать на патче — автоматизируй версию через changesets/semantic-release по conventional commits, чтобы номер отражал изменение. Кто это собрал доказывается provenance (аттестация источника сборки): публикуй из CI с --provenance и узким automation-токеном (не персональным), за 2FA/OIDC, чтобы тарбол из утёкшего токена вне твоего CI провалил проверку, а тихая подмена цепочки поставок стала видимой. А когда плохой релиз вырвался, забрать его назад нельзя — unpublish огорожен 72 часами без зависимых (правило left-pad), так что настоящий отзыв — опубликовать исправленную версию и пометить npm deprecate сломанную. Теперь, когда встретишь сломанную публикацию — пустой тарбол, утёкший секрет или поломку через caret-диапазон — ты будешь знать, какой гейт добавить выше по течению от npm publish, чтобы это не повторилось.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 5 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.