Симптом выглядит нелогично: исходники не менялись, commit тот же, а webpack выдаёт другой main.*.js. Цена ошибки — не только cache miss. Когда команда не знает границы входов, она спорит о случайности вместо того, чтобы показать изменение в байтах и найти владельца. Проблема начинается с слишком короткой модели: «webpack собирает source». На деле bundle зависит от разрешённого дерева пакетов, runtime, конфигурации, командной строки и данных, которые plugins читают во время работы. Одинаковый Git hash не делает эти входы одинаковыми автоматически.
Воспроизводимая сборка в этой заметке — проверяемое свойство конкретной команды: два чистых прогона с зафиксированным набором входов дают одинаковый набор байтов, либо различие явно объяснено. Это не сертификат для всех машин и не замена тестам. Такая формулировка полезна, потому что разрешает действовать: сначала назвать входы, потом сохранить их, потом сравнить output. Если один вход не фиксируется, он становится не мистикой, а точкой контракта.
Сборка как функция с внешними аргументами
Удобная модель для практики: artifact = build(source, dependencyTree, runtime, config, environment, generatedData). Git хранит в основном source и часть config. Lockfile хранит dependencyTree, но только если он действительно используется. Runtime — это версия Node, npm и иногда платформенные библиотеки для native dependency. Environment — режим сборки, locale, timezone, path, registry, системные переменные. Generated data — дата в banner, случайный ID, список файлов каталога, ответ внешнего API. Модель не требует заморозить вселенную. Она заставляет для каждого отличившегося байта спросить: какой аргумент его породил?
| Компонент функции | Пример дрейфа | Как увидеть | Чей следующий шаг |
|---|---|---|---|
source | Незаписанный generated file или другой commit | Hash HEAD и git status --short | Разработчик фиксирует или исключает generated source по правилам репозитория |
dependencyTree | Новый транзитивный пакет попал под semver-диапазон | Hash package-lock и log чистой установки | Владелец зависимостей обновляет lockfile отдельным diff |
runtime | Другой Node меняет синтаксическое преобразование или native module | node --version, npm --version | Проект задаёт поддерживаемую версию и способ её получить |
config | Другой mode, publicPath или define-значение | Команда, project config и безопасный diff параметров | Владелец build config делает параметр явным |
generatedData | Дата, абсолютный путь, порядок чтения каталога, случайный ID | Diff конкретного asset и его генератор | Владелец генератора фиксирует значение или документирует исключение |
Таблица не предлагает печатать всё окружение в лог. В конфигурации почти всегда есть секреты, а полный env делает сравнение шумным. Нужен белый список: версии tools, выбранная команда, hash lockfile, registry host без токена, mode и явно поддерживаемые build-переменные. Если после такого списка результат расходится, это не повод перейти к дампу секретов. Это повод сравнить фактический output и раскрыть следующий неназванный вход.
Lockfile фиксирует разрешение, а не весь процесс
npm отделяет package.json от lockfile не случайно. Manifest говорит, какой диапазон или пакет хочет проект; lockfile описывает конкретное дерево, включая resolved location и integrity. Наличие точной верхней версии в manifest не всегда закрывает транзитивные зависимости. Поэтому для сравнения «два прогона одной сборки» важно не только прочесть dependencies, а проверить, что оба прогона используют один и тот же package-lock.json. npm рекомендует коммитить этот файл именно для того, чтобы команда и системы установки получали одинаковое дерево.
npm ci полезен здесь не скоростью, а отказом от творчества. Он не пытается аккуратно совместить локальный node_modules с новым manifest и не чинит lockfile по ходу проверки. Если package.json и lockfile расходятся, остановка — правильный результат: проверяемого входа нет. Нельзя обходить её удалением lockfile или переходом на npm install. Сначала нужно решить, какое дерево зависимостей проект вообще выбирает, закоммитить этот выбор отдельным diff, а потом вернуться к проверке output.
Почему filename с contenthash — не весь доказательный набор
Webpack использует [contenthash] как hash содержимого asset, поэтому различное имя bundle — полезная лампа: bytes изменились. Но обратное заключение слишком сильное. Один файл может не сменить имя, а другой asset, HTML, source map или CSS уже расходятся. И наоборот, runtime или идентификаторы модулей способны менять несколько chunk names после малого изменения графа модулей. В guide webpack это разобрано на примере runtime и module IDs: без устойчивых идентификаторов изменение порядка разрешения может сдвинуть hash vendor chunk. Для диагноста это значит: filename — вход в расследование, manifest всех deployable files — доказательство его результата.
Не надо автоматически включать в сравнение всё, что лежит рядом с dist. Source map иногда содержит абсолютные пути. Статистика webpack может быть журналом, а не артефактом. Правильный вопрос: «какие файлы потребляет выкладка или браузер?» Составьте allowlist или директорию deployable output и храните её в build contract. Если source map тоже доставляется пользователю, он входит в manifest. Если нет — исключение должно быть явным, с причиной и владельцем. Молчаливое исключение создаёт ложное совпадение.
Hash отвечает на точный вопрос
SHA-256 в этом процессе — отпечаток набора уже выбранных байтов. Он не объясняет различие, не заменяет подпись пакета и не проверяет, что bundle работает. Его сила в сравнении: два одинаковых manifest дают одинаковый digest, а один изменённый файл меняет manifest. Чтобы это свойство не испортил порядок каталога, manifest строится из строк sha256 + path, отсортированных по пути. Сначала можно читать diff строк, затем сравнить итоговый hash для короткого отчёта. Если хранить только один digest, вы узнаете, что проблема есть, но потеряете место, где она возникла.
import { createHash } from 'node:crypto';
function digest(value) {
return createHash('sha256').update(value).digest('hex');
}
function manifest(files) {
return files
.map(({ path, bytes }) => digest(bytes) + " " + path)
.sort()
.join("\n") + "\n";
}
const first = manifest([
{ path: "dist/main.js", bytes: "console.log(same)\n" },
{ path: "dist/index.html", bytes: "<script src=main.js></script>\n" },
]);
const second = manifest([
{ path: "dist/index.html", bytes: "<script src=main.js></script>\n" },
{ path: "dist/main.js", bytes: "console.log(same)\n" },
]);
console.log(digest(first) === digest(second)); // true: порядок массива не влияет
В пакете есть автономный --run-fixture с этим правилом. Он использует sample lockfile и sample bytes, поэтому его выход нельзя назвать результатом npm, webpack или production. Он проверяет только алгоритм: перестановка одинаковых entries даёт тот же digest, изменение байта меняет digest. Для реальной сборки потребуется внешний журнал и manifest после фактической команды. Это ограничение важно: хорошая фикстура делает ровно одно утверждение и не прячется за знакомые слова.
Как локализовать первую разницу
- Сравнить commit, lockfile digest, версии Node/npm и команду. Если здесь есть отличие, остановиться: output ещё не предметный.
- Сравнить список путей manifest. Появившийся или пропавший файл указывает на ветку config, plugin или entry.
- Для общего пути сравнить SHA-256, затем сам файл. Для text asset достаточно diff; для binary нужен путь к генератору и размер.
- Если первым расходится HTML или runtime, проверить mode, public path, runtime chunk, define-переменные и данные banner/plugins.
- Если расходится зависимый bundle, проверить lockfile и лог установки до удаления cache. Новый package version может изменить graph.
- После одной правки повторить оба чистых прогона. Не складывать несколько гипотез в один commit: тогда manifest совпадёт или разойдётся без объяснения.
Типовые ложные исправления
Первое ложное исправление — закрепить версию webpack и объявить задачу закрытой. Версия сборщика важна, но date banner или разный mode продолжат менять output. Второе — добавить [contenthash] и сравнивать только имена. Это хорошо для cache policy, но не для полного доказательства. Третье — добавить timestamp в имя журнала и потом случайно включить журнал в manifest. Четвёртое — отключить source map, чтобы hash совпал, хотя map должен поставляться клиенту. В каждом случае результат выглядит спокойнее, но граница продукта изменилась молча.
Практический предел модели
Две одинаковые локальные сборки не утверждают, что Linux и macOS дадут одинаковые binary dependencies, что registry всегда отдаст те же tarball или что внешняя генерация никогда не изменится. Эти вопросы нужно добавлять по мере реальной стоимости ошибки. Для автора 2019 года полезнее сначала получить один воспроизводимый путь на поддерживаемой среде, чем написать манифест о supply chain, не умея сравнить два dist. Следующий разумный артефакт — короткий runbook с входами и последним результатом, а не ещё один набор флагов webpack.
Проверяемые источники
- 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