DarkRiDDeR13 мин

Разбор: разные release-файлы из одного commit в legacy webpack-проекте

JavaScriptnpmWebpackСборка

Симптом из поля: разработчик собрал release, коллега повторил тот же commit и получил другой vendors.*.js. Приложение открывается в обоих случаях, поэтому проблему предлагают отложить: «на сервере всё равно соберётся ещё раз». Цена такого решения появляется при rollback и расследовании: неясно, какой именно каталог проверяли, а cache получает два набора assets для одного номера версии. Здесь нельзя лечить исходя из названия файла. Нужно построить цепочку: что было входом, где байты впервые разошлись и какое изменение делает правило явным.

Кейс намеренно небольшой. В нём есть npm 6, webpack 4, legacy plugin и один deployable каталог. Нет реального production-лога, не заявляется запуск CI и не приводится hash настоящего сайта. Вместо этого есть метод, который можно применить в отдельной копии проекта: два чистых прогона, два журнала, два manifest и один минимальный diff. Такой масштаб важен: если сразу обвинить registry, minifier и операционную систему, команда получит много версий истории, но ни одного проверяемого факта.

Собираем карточку инцидента до исправления

Первая запись должна быть короче issue. Для обоих прогонов нужны: commit, состояние дерева, SHA-256 lockfile, версия Node/npm, команда сборки, ключевые build variables без секретов и manifest. Самый частый пропуск — чистота каталога. Если второй прогон выполняется после первого в том же node_modules и dist, непонятно, какой слой взял данные из прошлого результата. Поэтому берём два worktree или две disposable-копии. Это не бюрократия: npm ci сам удалит node_modules, а независимый каталог защищает от старых generated files.

Карточка двух прогонов
ПолеПрогон AПрогон BСмысл различия
Commitgit rev-parse HEADgit rev-parse HEADЛюбое отличие прекращает сравнение output
LockfileSHA-256 package-lock.jsonSHA-256 package-lock.jsonРазный digest означает разные зависимые входы
RuntimeNode/npm versionNode/npm versionРазная версия — гипотеза до чтения webpack diff
Командаnpm run build с modeТа же строка командыMode и env должны быть записаны, а не remembered
ManifestSHA-256 каждого deployable fileТо же представлениеDiff показывает первый фактический разрыв

Таблица не делает два окружения одинаковыми. Она делает отличие наблюдаемым. Например, если lockfile hash различается, нельзя продолжать спор о module id: изменилось dependencyTree. Если все поля равны, но differ только index.html, не нужно обновлять packages: нужно открыть HTML и найти переменную, timestamp или public path. Форма записи экономит время потому, что запрещает перескакивать через предыдущий слой без доказательства.

Отделяем разные зависимости от разного output

В первом сценарии два package-lock.json отличаются. Причина часто не в том, что разработчик «не сделал npm install». npm lockfile существует именно для фиксации дерева; без него следующий install может выбрать более свежий пакет в допустимом диапазоне, включая транзитивный. Действие: остановить сборочные прогоны, рассмотреть diff manifest и lockfile как отдельное изменение, затем выбрать один tree и снова запустить чистую установку. Нельзя копировать чей-то node_modules в архив: это маскирует проблему, не делает её ревизируемой.

Во втором сценарии lockfile и runtime совпадают, но config нет. Один запуск получил NODE_ENV=production, другой — development, либо webpack config читает переменную без default. Признак — разный набор chunks, source maps или public URL. Действие: сохранить точную команду в package script или release runbook, а требуемые переменные проверять до запуска. Выводить весь process.env не нужно: он может раскрыть секрет. Лучше перечислить одну переменную и её допустимые значения, если именно она меняет output.

Третий сценарий: lockfile и команда равны, bytes нет

Здесь появляется настоящая диагностическая работа. Сравнение manifest показывает самый ранний отличающийся файл. Допустим, это banner в главном bundle с текущей датой. Чистая установка ничего не изменит: dependencyTree уже одинаков. Нужно решить контракт generated data. Если дата служит только журналу, хранить её в отдельном файле доказательства, а не в deployable asset. Если дата нужна пользователю, считать её частью входа: передавать явно, записывать значение и ожидать разный output для разного release. Плохой выход — «не сравнивать этот файл», не объясняя, почему он участвует в выкладке.

Другой частый след — движение hash всех chunks после малого изменения graph. В webpack 4 runtime может содержать связь chunk/module IDs, а порядок разрешения влияет на эти IDs. Это не доказательство bug в webpack и не повод сразу править optimisation наугад. Сначала на маленьком diff проверить, менялся ли source graph и какой chunk расходится первым. Затем оценить, нужна ли стабилизация module IDs и выделение runtime. Цель не в том, чтобы любой локальный edit сохранял hash vendor файла, а в том, чтобы причина изменения была известна и проверяема.

Диагностическая схема двух чистых прогонов: сравниваются commit, lockfile, runtime и команда; затем сравнивается manifest. Ветка ведёт к dependency, config или generated output, а не к очистке cache.
Порядок проверки не сокращает техническую проблему. Он не даёт начать с webpack, пока разные входы ещё не исключены.

Минимальный fixture без обещаний о CI

Вместо скриншота terminal полезнее маленький fixture. Модуль P21 умеет запустить --run-fixture: он канонически сортирует sample paths, считает SHA-256 и проверяет два свойства. Перестановка входных записей не меняет manifest hash; изменение одного sample bundle меняет его. Fixture не загружает пакет из registry, не выполняет webpack и не создаёт production artifact. Его роль скромная, но честная: правило сравнения можно проверить отдельно от сети и конкретной машины.

node web/scripts/upgrade-2019-11.mjs --run-fixture

# Ожидаемая форма результата, не hash настоящего проекта:
{
  "fixtureOnly": true,
  "checks": {
    "sameEntriesDifferentOrder": true,
    "changedFileChangesManifest": true
  }
}

Реальный fixture строится поверх тех же правил, но после реальной команды build. Для двух копий проекта сохраняем first.log, second.log, first.manifest и second.manifest. Логи полезны только вместе с контекстом. Фраза «npm ci завершился успешно» без версии Node, lockfile hash и команды не позволяет повторить даже успешный сценарий. В то же время log не должен хранить auth header, token или полный URL private registry. Проверка сборки не отменяет базовую гигиену секретов.

Маршрут исправления одной причины

  1. Взять два новых каталога на одном commit. До установки записать hash lockfile, Node/npm и выбранную build-команду.
  2. Запустить npm ci в каждом каталоге. Расхождение manifest/package-lock считается отдельной задачей, а не поводом продолжить.
  3. Собрать и создать отсортированный SHA-256 manifest только для deployable output.
  4. Сравнить manifest; для первого отличающегося path открыть bytes и его генератор.
  5. Отнести отличие к одному владельцу: dependency tree, runtime/config или generated data. Зафиксировать только эту причину отдельным diff.
  6. Повторить два чистых прогона. При совпадении сохранить короткий результат; при расхождении не скрывать файл, а продолжить от следующего earliest diff.
  7. Определить, где живёт проверка дальше. Она может остаться release checklist, пока нет доказательства, что её нужно автоматизировать.

Что не помогло бы в этом кейсе

Удалить cache вручную — полезный санитарный приём, но это не объяснение. Поменять output filename на timestamp — наоборот, гарантированно создаст разные paths. Выполнить npm update перед повторной проверкой — изменит dependencyTree и разрушит исходный эксперимент. Закрепить абсолютную версию одной прямой зависимости — не обязательно остановит транзитивный дрейф без lockfile. Наконец, сравнить только размер main.js — недостаточно: одинаковый размер допускает разные bytes, а HTML, CSS и дополнительные chunks останутся без проверки.

Критерий закрытия и обратимый шаг

Кейс закрыт не когда manifest «однажды совпал», а когда в repository или release-note есть воспроизводимый маршрут: какие inputs записать, как сделать две чистые установки, где лежит manifest и какое различие считать нормальным. Исправление должно быть обратимо. Например, если project config начинает требовать BUILD_VERSION, добавьте явную ошибку при его отсутствии и document default для локальной разработки, а не встраивайте переменную без следа. Если это решение ломает legacy deploy, можно убрать обязательность одним малым изменением и сохранить fixture как диагноз.

В этом пакете не было реального production прогона и не появилось нового CI job. Есть три подробные статьи, собственные SVG и автономная hash-фикстура. Они дают следующий разговор с проектом: «вот выбранные inputs, вот manifest, вот первый файл различия». Для команды 2019 года это уже сильнее общего совета «закрепите зависимости», потому что совет превратился в действие, журнал и проверяемый предел вывода.

Проверяемые источники

  • npm 6: package-lockspackage-lock.json описывает зафиксированное дерево зависимостей; semver-диапазон и транзитивные пакеты без него способны дать другой node_modules
  • npm 6: package-lock.json — описаны поля version, resolved и integrity и назначение lockfile в корне репозитория
  • npm 6: npm cinpm ci требует существующий lockfile, прекращает работу при расхождении с package.json, удаляет старый node_modules и не меняет manifest или lockfile
  • npm 6: npm config — параметры npm могут прийти из CLI, окружения, npmrc или package.json; источник конфигурации надо записывать рядом с прогоном
  • webpack: Caching[contenthash] связан с содержимым asset; guide отдельно разбирает влияние runtime и module IDs на имена файлов
  • Node.js: CryptocreateHash() создаёт hash-объект; в фикстуре он применяется к lockfile и каноническому списку файлов, а не выдаётся за подпись release