Симптом неприятный и дорогой: один commit собирают два разработчика, а в dist разные имена файлов или разные байты. На первом шаге это часто называют «кэшем webpack» и запускают сборку ещё раз. Цена такого лечения выше лишних минут: в review нельзя понять, что именно уйдёт в релиз, а исправление на одной машине не становится проверяемым для другой. Проблема не в самом хеше. Хеш честно сообщает, что в цепочку вошли разные данные или разный порядок работы. Задача — превратить это сообщение в короткое расследование.
Для проекта на npm и webpack 2019 года не нужно строить абстрактную платформу. Надо договориться о входах: исходный commit, package-lock.json, версия Node и npm, команда, важные параметры окружения и конфигурация сборщика. Затем два раза собрать в отдельной копии дерева и сравнить не названия «на глаз», а канонический список файлов с SHA-256. Такой список не доказывает, что release безопасен. Он отвечает на более узкий вопрос: одинаковый ли output у записанных входов.
Сначала отделяем сборку от окружения разработчика
У обычного npm install есть законное право обновить дерево зависимостей и записать lockfile. В рабочем цикле это удобно, но при проверке повторяемости смешивает два действия: разрешение версий и установку уже разрешённого дерева. Для контрольного прогона нужен другой контракт. npm 6 описывает npm ci как чистую установку: команда требует lockfile, прекращает работу при расхождении с package.json, удаляет прежний node_modules и не пишет manifest или lockfile. Это не делает все файлы мира одинаковыми, но закрывает случайную переустановку транзитивной зависимости.
Чистая установка не означает «запустить на домашней машине без записи контекста». npm берёт настройки из флагов, переменных среды, .npmrc, пользовательского и глобального npmrc. Версия runtime влияет на транспиляцию, native-пакеты и скрипты жизненного цикла. Поэтому журнал хранит не секреты и не полный дамп окружения, а минимум, который позволяет повторить путь: hash commit, версию Node/npm, выбранный registry без токена, hash lockfile, команду build и SHA-256 output-manifest. Если в логе есть пароль, его нельзя считать доказательством: он уже инцидент.
| Вход | Почему может менять результат | Что записать | Чего не делать |
|---|---|---|---|
| Исходный код | Другой commit, незакоммиченный файл или generated source меняет bundle | git rev-parse HEAD и состояние дерева | Не писать «актуальный master» вместо точного hash |
| Дерево пакетов | Semver-диапазон и транзитивный пакет могут разрешиться иначе | SHA-256 package-lock.json и факт npm ci | Не перегенерировать lockfile во время сравнения |
| Node, npm и npmrc | Runtime и параметры установки участвуют в сборочных скриптах | Только версии и безопасные значения registry/config | Не копировать токены, proxy-пароли или весь env |
| Команда и config | Другой mode, public path или define-переменная меняют bytes | Полную команду и hash конфигурации | Не подменять production-команду похожей локальной |
| Output | Имя файла не доказывает равенство всех файлов | Отсортированный manifest SHA-256 | Не сравнивать только один главный bundle |
Фикстура: журнал, lockfile и manifest
Ниже не «скрипт для CI» и не обещание, что он уже работал в production. Это маленькая фикстура для disposable-копии репозитория. Она сначала сохраняет наблюдаемые входы, потом выполняет ровно ту сборочную команду, которую проект использует, и только после неё создаёт manifest. Команду npm ci нельзя подменять npm install: во втором случае можно одновременно искать ошибку и незаметно менять объект расследования. Если проект не поддерживает npm 6 или применяет Yarn, нужно выбрать эквивалент чистой установки из документации своего менеджера.
# Запускать в отдельной копии рабочего дерева, не в папке с незаписанной работой.
mkdir -p build-proof
{
git rev-parse HEAD
node --version
npm --version
npm config get registry
shasum -a 256 package-lock.json
npm ci
npm run build
} > build-proof/first.log 2>&1
node tools/proof-manifest.mjs dist > build-proof/first.manifest
shasum -a 256 build-proof/first.manifest > build-proof/first.manifest.sha256
Скрипт tools/proof-manifest.mjs ниже читает байты каждого файла, сортирует относительные пути и печатает digest вместе с путём. Сортировка важна: файловая система не обязана возвращать каталог в одном и том же порядке. Manifest превращает набор из десятков assets в один diff, который можно показать коллеге. При расхождении не начинайте с поиска «правильного» хеша. Сначала сравните сами manifest: если отличается один runtime-файл, путь расследования иной, чем когда changed half of node_modules или HTML содержит момент времени.
// tools/proof-manifest.mjs
// Запускать в отдельной копии репозитория после сборки.
import { createHash } from 'node:crypto';
import { readdir, readFile } from 'node:fs/promises';
import { join } from 'node:path';
async function filesUnder(directory, prefix = '') {
const entries = await readdir(directory, { withFileTypes: true });
const nested = await Promise.all(entries.map(async (entry) => {
const relative = prefix ? prefix + '/' + entry.name : entry.name;
const absolute = join(directory, entry.name);
return entry.isDirectory() ? filesUnder(absolute, relative) : [relative];
}));
return nested.flat();
}
function digest(bytes) {
return createHash('sha256').update(bytes).digest('hex');
}
const outputDirectory = process.argv[2] || 'dist';
const files = (await filesUnder(outputDirectory)).sort();
for (const relative of files) {
const bytes = await readFile(join(outputDirectory, relative));
process.stdout.write(digest(bytes) + ' ' + relative + '\n');
}
Два прогона должны различаться только именем журнала
Сделайте второй прогон с тем же commit и тем же lockfile, но в свежей рабочей копии. Он должен получить собственные second.log и second.manifest. Нельзя брать уже построенный dist как «второй результат»: тогда мы проверим сохранность каталога, а не повторяемость сборки. Перед сравнением полезно проверить, что оба manifest содержат одинаковое число строк. Это не заменяет hash, но быстро ловит случай, когда в одном прогоне вообще не возник CSS, source map или chunk.
# Во второй чистой копии с тем же commit и package-lock.json:
mkdir -p build-proof
npm ci > build-proof/second.log 2>&1
npm run build >> build-proof/second.log 2>&1
node tools/proof-manifest.mjs dist > build-proof/second.manifest
diff -u build-proof/first.manifest build-proof/second.manifest
shasum -a 256 build-proof/first.manifest build-proof/second.manifest
Если manifest совпал, фиксируем результат узко: «в этих двух чистых прогонах с записанными входами SHA-256 manifest совпал». Не надо расширять вывод до «сборка всегда воспроизводима»: не проверены другая ОС, другой CPU, другой registry и будущие версии инструментов. Если manifest различается, журнал позволяет сначала сравнить версии и lockfile, потом команды и config, и только после этого идти в webpack output. Простой порядок избавляет от бесконечного удаления cache.
Как читать расхождение без гадания
Первый тип расхождения — разный lockfile hash или разные версии npm. Здесь нет смысла обсуждать webpack: установка получила разные входы. Второй — lockfile одинаков, а в журнале другая команда, NODE_ENV, --mode или project npmrc. Это граница конфигурации. Третий — входы записаны одинаково, но расходятся assets. Тогда смотрим на самый ранний различающийся файл и на его содержимое. В webpack 4 имя с [contenthash] связано с content asset, но runtime, manifest и идентификаторы модулей могут сделать отличие шире, чем изменение одного исходника. Не делайте вывод по одному filename.
Особый класс — время, путь, locale и случайность. Banner-плагин может вставить дату, генератор документации — абсолютный путь, а код — случайный идентификатор. Такой output не лечится новой фиксацией npm-пакета: надо найти генератор и решить, должен ли он получить фиксированное значение, исключаться из сравнения по явному правилу или жить вне deployable artifact. Исключение нельзя молча сделать через grep -v. Его нужно назвать в контракте и объяснить, почему файл не влияет на поставку.
Маршрут внедрения в старый webpack-проект
- Выбрать одну реальную команду сборки и отдельную копию дерева. Записать commit, runtime и hash lockfile до установки.
- Заменить для контрольного прогона обычный install на
npm ci; если команда падает на рассинхронизации, исправить manifest/lockfile отдельным изменением. - Добавить manifest по файлам deployable-каталога. Сначала хранить его рядом с журналом, не в public assets.
- Повторить сборку в новой копии и сравнить manifest. При различии классифицировать вход: dependency, config, environment или generated output.
- Внести минимальное исправление одной причины. Повторить два прогона и оставить рядом diff/лог без секретов.
- Только после стабильного fixture решать, где этот контроль будет жить дальше: локальная release-инструкция, отдельный job или проверка перед выкладкой.
Что считается готовым, а что нет
Готовый первый шаг — не красивый badge и не обещание «у нас детерминированный webpack». Это папка доказательства, из которой другой разработчик понимает exact commit, lockfile hash, runtime, команду и отличие либо совпадение manifest. Для наследуемого проекта этого достаточно, чтобы следующая ошибка не начиналась с памяти о том, как однажды помогла очистка cache. Следующий шаг определяется причиной: зафиксировать версию инструмента, убрать timestamp, оформить project npmrc или разделить runtime chunk. Один symptom не требует сразу переделывать всю цепочку доставки.
Ограничение результата
Эта практика не выполняла npm ci и webpack для данного архива и не выдаёт sample hash за hash сайта. В модуле пакета есть автономная hash-фикстура: она доказывает только свойство канонического manifest — одинаковые sample files в разном порядке дают один SHA-256, изменённый байт даёт другой. Реальный проект должен получить собственные журнал и manifest на своей версии Node/npm. Именно запись этих артефактов отделяет проверку от убедительного, но пустого рассказа.
Проверяемые источники
- 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