Симптом из поля: разработчик собрал 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 | Смысл различия |
|---|---|---|---|
| Commit | git rev-parse HEAD | git rev-parse HEAD | Любое отличие прекращает сравнение output |
| Lockfile | SHA-256 package-lock.json | SHA-256 package-lock.json | Разный digest означает разные зависимые входы |
| Runtime | Node/npm version | Node/npm version | Разная версия — гипотеза до чтения webpack diff |
| Команда | npm run build с mode | Та же строка команды | Mode и env должны быть записаны, а не remembered |
| Manifest | SHA-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 файла, а в том, чтобы причина изменения была известна и проверяема.
Минимальный 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. Проверка сборки не отменяет базовую гигиену секретов.
Маршрут исправления одной причины
- Взять два новых каталога на одном commit. До установки записать hash lockfile, Node/npm и выбранную build-команду.
- Запустить
npm ciв каждом каталоге. Расхождение manifest/package-lock считается отдельной задачей, а не поводом продолжить. - Собрать и создать отсортированный SHA-256 manifest только для deployable output.
- Сравнить manifest; для первого отличающегося path открыть bytes и его генератор.
- Отнести отличие к одному владельцу: dependency tree, runtime/config или generated data. Зафиксировать только эту причину отдельным diff.
- Повторить два чистых прогона. При совпадении сохранить короткий результат; при расхождении не скрывать файл, а продолжить от следующего earliest diff.
- Определить, где живёт проверка дальше. Она может остаться 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-locks —
package-lock.jsonописывает зафиксированное дерево зависимостей; semver-диапазон и транзитивные пакеты без него способны дать другойnode_modules - npm 6: package-lock.json — описаны поля
version,resolvedиintegrityи назначение lockfile в корне репозитория - npm 6: npm ci —
npm 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: Crypto —
createHash()создаёт hash-объект; в фикстуре он применяется к lockfile и каноническому списку файлов, а не выдаётся за подпись release