Как мы догнали зависимости фронта до актуальных версий (TypeScript 6 и pnpm 11), не сломав прод

Веб Штурм
  • dependencies
  • TypeScript
  • SvelteKit
  • CI/CD
  • Docker Swarm
  • pnpm
  • supply-chain
  • baseline
  • risk-management

Мы догнали зависимости фронтенда до актуальных мажорных версий — включая переход на TypeScript 6 — без единой секунды простоя на проде. Метод: каждый апгрейд = отдельный шаг с автоматическим гейтом (проверка типов + тесты + сборка), baseline-снапшот накопленного техдолга, kill-switch на проблемных инструментах и реальный откат-дрилл в Docker Swarm до того, как откат понадобился. Ниже — как это устроено и где мы споткнулись.

Зачем вообще догонять зависимости до сдачи

Проект на современном стеке: SvelteKit 2 / Svelte 5 / Tailwind 4. Стек свежий, но мажоры зависимостей всё равно набегают — lucide, marked, сам TypeScript, тулчейн проверки типов, пины пакетного менеджера.

Ключевое решение: догнать мажоры до сдачи проекта, в окне, когда цена ошибки минимальна. Нет живых пользователей, нет SLA, откат не стоит ничего, кроме нескольких минут. После сдачи тот же апгрейд превращается в согласование окна обслуживания и риск простоя. «Отложить на потом» — это не экономия, а перенос дорогой работы в дорогое время.

Пилот прогнали на одном проекте монорепо — чтобы получить воспроизводимый чеклист (recipe) для остальных.

Принцип: один бамп — один шаг — один гейт

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

Вместо этого — конвейер из мелких шагов:

sweep (патчи/миноры в пределах текущих мажоров)
  → низкие мажоры (lucide 0→1, marked)
    → TypeScript 5 → 6
      → пины пакетного менеджера (pnpm)

После каждого шага — один и тот же гейт:

  1. Проверка типов (svelte-check) — не появилось ли новых ошибок сверх baseline.
  2. Тесты (vitest) — зелёные.
  3. Сборка (vite build) — собирается.

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

Baseline: фиксируем техдолг снапшотом

Проблема любого крупного фронта: проверка типов уже выдаёт сотни ошибок ещё до начала апгрейда. Если требовать «ноль ошибок», апгрейд не сдвинется с места — сначала придётся вычистить весь накопленный долг.

Решение — baseline-подход. Фиксируем число известных ошибок снапшотом и считаем регрессией только НОВУЮ ошибку сверх baseline, а не любую ошибку вообще.

Нюанс из нашего случая: первоначальная оценка долга была ~971 ошибка, но её раздувал сломанный typecheck — два теста с зарезервированным +-префиксом в имени ломали svelte-kit sync, и проверка типов не запускалась вовсе. После починки реальный baseline оказался всего 12. Урок: сначала почини сам инструмент проверки — иначе baseline фиксирует мусор, а не долг.

Скрипт прогоняет svelte-check в машинном формате и сводит ошибки по файлам:

# baseline.sh (суть)
# машинный вывод: по строке на диагностику, поле 2 = severity, поле 3 = файл
svelte-check --output machine 2>/dev/null \
  | awk -F' ' '$2 == "ERROR" { print $3 }' \
  | sort | uniq -c | sort -rn > svelte-check-baseline.txt

Метрика — файл → число ошибок. На следующих шагах новый прогон сравнивается с этим снапшотом: любая новая ошибка в файле, которого не было в baseline (или превышение счётчика) — регрессия, гейт красный.

Гард на краш. Отдельная тонкость: svelte-check может не просто найти ошибки, а упасть с ненулевым кодом (краш тулчейна — см. ниже про TS6). Пустой вывод при краше нельзя трактовать как «ноль ошибок» — это ложно-зелёный результат. Скрипт явно отличает «прошло, нашло N ошибок» от «инструмент упал»:

svelte-check --output machine > out.txt 2>err.txt
code=$?
# код 0/1 = инструмент отработал (1 = найдены ошибки, это норма для baseline);
# всё остальное = краш → НЕ считаем результат валидным, гейт красный
if [ "$code" -gt 1 ]; then
  echo "svelte-check упал (exit=$code) — baseline невалиден" >&2
  exit "$code"
fi

TypeScript 5 → 6: код прошёл, инструмент — нет

Самый показательный шаг. Код приложения перешёл на TypeScript 6 без единой правки — ни одного нового типа ошибок в самом приложении.

Сломался инструмент проверки типов: svelte-check@4.3.5 крашился на TS6, хотя в его диапазоне совместимости TypeScript 6 формально значился. Гард на краш (выше) поймал это сразу — гейт стал красным не из-за ошибок в коде, а из-за ненулевого exit самого тулчейна.

Лечение — апгрейд svelte-check до 4.6, где краш на TS6 устранён. После этого гейт снова зелёный, baseline воспроизводится.

Урок: «формально поддержано» не равно «реально работает». Матрица совместимости в package.json — это обещание, а не гарантия. Поэтому каждый шаг с потенциально хрупким тулчейном получает kill-switch: явное условие, по которому шаг немедленно откатывается (в данном случае — фиксация рабочей пары typescript@6 + svelte-check@4.6, а не «любой svelte-check из диапазона»).

pnpm-дисциплина: пин через corepack

Последний шаг — пины пакетного менеджера. Разные версии pnpm по-разному генерируют lock-файл; «плавающая» версия у разработчика рассинхронизирует lock с тем, что соберёт CI.

Лечение — пин версии через corepack, чтобы и локально, и в CI работала одна и та же версия pnpm:

// package.json
{
  "packageManager": "pnpm@10.22.0"
}
# локально приводим окружение к пину репозитория
corepack prepare pnpm@10.22.0 --activate

Это закрывает класс багов «у меня собирается, в CI — нет»: lock-файл становится детерминированным.

Продолжение: унификация на pnpm 11 и пять breaking changes

Пилот пинил pnpm@10.22.0. Позже мы догнали и сам пакетный менеджер — унифицировали весь workspace (три фронтенда, шесть фоновых воркеров, статический сайт) на единую pnpm@11.8.0, устранив рассинхрон версий между проектами. Это вскрыло отдельный пласт ломающих изменений: pnpm 11 — это в основном усиление supply-chain-защиты, и четыре из пяти барьеров приходится осознанно ослаблять для доверенного контура. Каждый — несколько строк в pnpm-workspace.yaml или Dockerfile.

  1. strictDepBuilds. Нативный build-скрипт зависимости (postinstall у esbuild, sharp) без явного одобрения теперь ошибка ERR_PNPM_IGNORED_BUILDS, а не предупреждение — даже при --frozen-lockfile. Лечение — allowlist:

    # pnpm-workspace.yaml
    allowBuilds:
      esbuild: true
      sharp: true

    Грабля: pnpm approve-builds дописывает блок за вас, но оставляет плейсхолдер set this to true or false — его нужно заменить на true.

  2. pnpm.overrides из package.json больше не читается — переносить дословно в pnpm-workspace.yaml (поле overrides:). Это часто и есть ваши CVE-пины: потеря override молча вернёт уязвимую транзитивную версию.

  3. minimumReleaseAgesupply-chain-гейт, по умолчанию отвергает пакеты моложе суток (защита от свежескомпрометированных версий). При ре-резолве это роняет свежеопубликованный внутренний пакет → minimumReleaseAge: 0. Грабля для Docker: в multi-stage сборке runner-стадия должна копировать pnpm-workspace.yaml, иначе гейт вернётся на установке prod-зависимостей.

  4. trustLockfile. При --frozen-lockfile pnpm 11 заново сверяет весь lock с метаданными реестра. Для приватного пакета из self-hosted-реестра это даёт ERR_PNPM_TARBALL_URL_MISMATCH — причём даже у проектов monorepo, которые этот пакет не используют (lock общий на весь workspace). Лечение — trustLockfile: true (доверять закоммиченному локу, как в pnpm 9/10).

  5. Переменные ${TOKEN} в закоммиченном проектном .npmrc не раскрываются — защита от утечки секрета на враждебный реестр. Маппинг scope→registry по-прежнему читается, а токен подаём из доверенного источника. В Docker — литералом из BuildKit-секрета в эфемерный ~/.npmrc на время установки, с удалением после.

Сам lock переписывать массово не нужно: формат lockfileVersion 9.0 совместим с pnpm 11 — после обновления он нередко говорит «Already up to date». Главное — пин packageManager в каждом package.json (локаль = CI).

Бонус-урок: «мерцающий» CI-фейл — это конкуренция за ресурс, а не флейки

Уже после унификации деплой сайта стабильно падал при пуше в master, но проходил при ручном запуске. Лог упавшего шага показал не ошибку сборки, а установку пакетного менеджера:

npm install -g pnpm@11.8.0
npm error ENOTEMPTY: directory not empty, rename '.../node_modules/pnpm' -> '.../.pnpm-XXXXXX'

Корень: при пуше параллельно стартуют несколько workflow на одном self-hosted-раннере с общим toolcache — два конкурентных npm install -g pnpm дерутся за один global-каталог. Ручной запуск идёт в одиночку, поэтому баг «мерцал». Фикс из двух слоёв: corepack вместо npm install -g pnpm (corepack идёт с Node и не делает rename в общий prefix) и общий concurrency-group, сериализующий workflow на раннере. Урок шире pnpm: «мерцающий» фейл, зависящий от способа запуска, — почти всегда конкуренция за общий ресурс, а не флейки-тест; диагноз ставится по логу конкретного упавшего шага.

Откат-дрилл: доказать откат ДО того, как он нужен

Развёртывание автоматическое: мердж в master → CI собирает образ → обновление сервиса в Docker Swarm. У такого деплоя есть встроенный откат — возврат сервиса на предыдущий тег образа.

Но «встроенный откат» на бумаге и рабочий откат — разные вещи. Поэтому мы провели откат-дрилл: намеренно откатили сервис на прошлую версию образа и вернули обратно.

# намеренный откат на предыдущий тег — проверка, что механизм рабочий
docker service update --rollback webshturm_webshturm-site
# ...убедились, что сайт отвечает старой версией, затем вернулись на новый тег
docker service update --image registry.example/webshturm-site:<new-tag> \
  webshturm_webshturm-site

Откат отработал примерно за 12 секунд. Главная ценность — не сами 12 секунд, а доказательство до инцидента, что путь отступления существует и работает. Когда откат понадобится по-настоящему, в стрессовой ситуации не придётся выяснять, рабочий ли он.

Побочный улов: чиним то, что было сломано до нас

Дисциплина «гейт после каждого шага» вскрыла несколько пред-существующих дефектов, не связанных напрямую с апгрейдом:

  • Сломанная проверка типов — конфигурация, при которой svelte-check не покрывал часть проекта; baseline-прогон это обнажил.
  • Рассинхрон lock-файла — lock не соответствовал манифесту ещё до начала работ; пин pnpm зафиксировал детерминированную генерацию.

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

Результат и recipe

  • Фронт на актуальных мажорах, включая TypeScript 6.
  • Ноль простоя на проде.
  • Воспроизводимый чеклист (recipe) для остальных проектов: sweep → низкие мажоры → TS6 (с kill-switch) → пины pnpm, гейт после каждого шага, baseline для техдолга, откат-дрилл перед боем.

Тяжёлые мажоры (например, мажор сборщика и крупные доменные плагины) сознательно вынесены во вторую фазу — пилот не пытается съесть всё разом, он отрабатывает безопасный конвейер.

Ссылки

Часто задаваемые вопросы

Зачем обновлять зависимости до сдачи проекта, а не после?
До сдачи цена ошибки минимальна: нет живых пользователей, нет SLA, откатить можно без последствий. После сдачи каждый мажорный апгрейд — это риск простоя и согласование окна обслуживания. Мы используем предсдаточное окно, чтобы войти в эксплуатацию уже на актуальных версиях.
Что такое baseline-подход к техническому долгу?
Baseline — это снапшот количества известных ошибок проверки типов на момент старта. Регрессией считается только НОВАЯ ошибка сверх baseline, а не любая ошибка вообще. Это позволяет двигаться вперёд, не требуя сначала вычистить весь накопленный долг, и при этом ловить ухудшения немедленно. В нашем случае первоначальная оценка ~971 оказалась артефактом сломанного typecheck — после его починки реальный baseline составил всего 12 ошибок.
TypeScript 6 потребовал правок кода приложения?
Нет, код приложения прошёл TS5→TS6 без изменений. Сломался инструмент проверки типов: svelte-check@4.3.5 крашился на TS6, хотя формально заявлен совместимым. Помог апгрейд svelte-check до 4.6. Вывод: «формально поддержано» не равно «реально работает» — поэтому нужен kill-switch.
Как обеспечивается откат, если обновление всё-таки сломает прод?
Развёртывание идёт через Docker Swarm: мердж в master запускает CI, который собирает образ и обновляет сервис. Откат — это возврат сервиса на предыдущий тег образа, занимает около 12 секунд. Перед тем как откат понадобится, мы провели откат-дрилл: намеренно откатились на прошлую версию и вернулись обратно, доказав, что механизм рабочий.
Почему каждый апгрейд делается отдельным шагом, а не пачкой?
Если обновить десять пакетов разом и что-то сломается — непонятно, какой именно бамп виноват, и приходится откатывать всё. Пошаговый апгрейд с гейтом после каждого шага локализует поломку до одного пакета: видно ровно, что и где сломалось, откат точечный, история коммитов читается как журнал.
Что ломается при переходе с pnpm 10 на pnpm 11?
Пять вещей, почти все — усиление supply-chain-защиты. 1) strictDepBuilds: build-скрипт зависимости без явного одобрения теперь ошибка, а не предупреждение — решается списком allowBuilds. 2) поле pnpm.overrides из package.json больше не читается — переносится в pnpm-workspace.yaml. 3) minimumReleaseAge отвергает пакеты моложе суток — выключается значением 0. 4) trustLockfile: frozen-install заново сверяет весь lock с реестром и роняет приватные пакеты — возвращается опцией trustLockfile: true. 5) переменные окружения в закоммиченном .npmrc не раскрываются — токен подаётся из ~/.npmrc или BuildKit-секрета. Каждое чинится несколькими строками конфига.
Почему деплой падал при пуше, но проходил при ручном запуске?
Это была не флейки-сборка, а конкуренция за ресурс: при пуше параллельно стартовали несколько workflow на одном раннере с общим toolcache, и два конкурентных `npm install -g pnpm` дрались за один каталог (ошибка ENOTEMPTY). Лечение — corepack вместо глобальной установки плюс concurrency-group для сериализации. Общий урок: «мерцающий» фейл, зависящий от способа запуска, почти всегда означает гонку за общий ресурс.