Как мы догнали зависимости фронта до актуальных версий (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)
После каждого шага — один и тот же гейт:
- Проверка типов (
svelte-check) — не появилось ли новых ошибок сверх baseline. - Тесты (
vitest) — зелёные. - Сборка (
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.
-
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. -
pnpm.overridesизpackage.jsonбольше не читается — переносить дословно вpnpm-workspace.yaml(полеoverrides:). Это часто и есть ваши CVE-пины: потеря override молча вернёт уязвимую транзитивную версию. -
minimumReleaseAge — supply-chain-гейт, по умолчанию отвергает пакеты моложе суток (защита от свежескомпрометированных версий). При ре-резолве это роняет свежеопубликованный внутренний пакет →
minimumReleaseAge: 0. Грабля для Docker: в multi-stage сборке runner-стадия должна копироватьpnpm-workspace.yaml, иначе гейт вернётся на установке prod-зависимостей. -
trustLockfile. При
--frozen-lockfilepnpm 11 заново сверяет весь lock с метаданными реестра. Для приватного пакета из self-hosted-реестра это даётERR_PNPM_TARBALL_URL_MISMATCH— причём даже у проектов monorepo, которые этот пакет не используют (lock общий на весь workspace). Лечение —trustLockfile: true(доверять закоммиченному локу, как в pnpm 9/10). -
Переменные
${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 для техдолга, откат-дрилл перед боем.
Тяжёлые мажоры (например, мажор сборщика и крупные доменные плагины) сознательно вынесены во вторую фазу — пилот не пытается съесть всё разом, он отрабатывает безопасный конвейер.
Ссылки
- Кейс: три волны модернизации webshturm.ru — контекст по стеку и инфраструктуре
- Subagent-driven development — как мы декомпозируем крупные изменения
- Dual-mode, цены из Supabase и две инфра-гочи — другой пример «обновили, не сломав прод»