This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
# Производство редакционных партий
|
# Производство редакционных партий
|
||||||
|
|
||||||
На 31 июля 2026 года строгий аудит проходит 64 из 358 созданных материалов. Остальные 294 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить.
|
На 31 июля 2026 года строгий аудит проходит 67 из 358 созданных материалов. Остальные 291 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить.
|
||||||
|
|
||||||
## Одна партия
|
## Одна партия
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
# Ноябрь 2019 — тройное ревью автономного пакета P21 «Воспроизводимые сборки»
|
||||||
|
|
||||||
|
Статус: **принят в publication registry 31 июля 2026 года**. Три revision
|
||||||
|
накладываются только по stable slug и сохраняют даты и авторов базового архива:
|
||||||
|
|
||||||
|
- <code>editorial-2019-11-practice-reproducible-builds</code>;
|
||||||
|
- <code>editorial-2019-11-mechanism-reproducible-builds</code>;
|
||||||
|
- <code>editorial-2019-11-field-reproducible-builds</code>.
|
||||||
|
|
||||||
|
Модуль экспортирует ровно три revision. В объектах нет <code>date</code> и
|
||||||
|
<code>author</code>: эти поля принадлежат исходным записям архива. При прямом
|
||||||
|
запуске с <code>--print-revisions</code> stdout содержит только JSON; отдельный
|
||||||
|
<code>--run-fixture</code> печатает JSON учебной hash-проверки.
|
||||||
|
|
||||||
|
## Проход 1. Факты и техника — пройдено
|
||||||
|
|
||||||
|
| Утверждение или решение | Первичный источник | Проверенная граница |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Lockfile фиксирует конкретное дерево, включая транзитивные зависимости | [npm 6: package-locks](https://docs.npmjs.com/cli/v6/configuring-npm/package-locks/) и [package-lock.json](https://docs.npmjs.com/cli/v6/configuring-npm/package-lock-json/) | Текст не утверждает, что lockfile фиксирует OS, время, webpack config или внешний генератор. Он фиксирует разрешение пакетов, когда используется в контролируемом install. |
|
||||||
|
| <code>npm ci</code> — чистая установка с отказом при рассинхронизации lockfile и package.json | [npm 6: npm ci](https://docs.npmjs.com/cli/v6/commands/npm-ci/) | Статьи не подменяют им рабочий <code>npm install</code> и не обещают, что npm ci устраняет drift плагина или даты в asset. |
|
||||||
|
| npm config может прийти из CLI, окружения, npmrc и package.json | [npm 6: npm config](https://docs.npmjs.com/cli/v6/commands/npm-config/) | В журнал предлагается whitelist безопасных значений, а не полный <code>env</code>, токены или auth headers. |
|
||||||
|
| <code>[contenthash]</code> связан с содержимым asset, но runtime и module IDs способны расширить diff chunks | [webpack: Caching](https://webpack.js.org/guides/caching/) | Имя одного файла названо сигналом, а не доказательством равенства всего dist. Проверяется manifest всех deployable files. |
|
||||||
|
| SHA-256 нужен для сравнения выбранных bytes | [Node.js: Crypto](https://nodejs.org/api/crypto.html) | Hash не объявлен подписью релиза, доказательством работы приложения или результатом production-сборки. |
|
||||||
|
|
||||||
|
Автономная fixture не скачивает зависимости и не запускает webpack. Она
|
||||||
|
проверяет только алгоритм сравнения: перестановка одинаковых entries даёт тот
|
||||||
|
же hash manifest, изменение одного bytes даёт другой. Фактический вывод:
|
||||||
|
|
||||||
|
<pre><code>{
|
||||||
|
"fixtureOnly": true,
|
||||||
|
"lockfileSha256": "d2976c1b0ca05326497d2b8157ee39b3ffc6f5ad6481c119e2fd769d3e6c8204",
|
||||||
|
"firstManifestSha256": "adc6fefae98f7874289579da4efcf252217989cbc9caf32fc8bb830f6eeac75e",
|
||||||
|
"secondManifestSha256": "adc6fefae98f7874289579da4efcf252217989cbc9caf32fc8bb830f6eeac75e",
|
||||||
|
"changedManifestSha256": "a521a4c7df02a402a2ab7e71ff690554cdf686faf831aaefe7d171079d15e8fe",
|
||||||
|
"checks": {
|
||||||
|
"sameEntriesDifferentOrder": true,
|
||||||
|
"changedFileChangesManifest": true
|
||||||
|
}
|
||||||
|
}</code></pre>
|
||||||
|
|
||||||
|
Следовательно, fixture подтверждает только канонизацию sample manifest. В
|
||||||
|
пакете нет заявления о CI, registry install, браузерном релизе или production
|
||||||
|
output. Для настоящего проекта статьи требуют записать собственные commit,
|
||||||
|
lockfile digest, Node/npm, команду, журнал и manifest после реального запуска.
|
||||||
|
|
||||||
|
## Проход 2. Редактура и голос М2 / 2019 — пройдено
|
||||||
|
|
||||||
|
| Ревизия | Симптом и стоимость в начале | Главный технический вопрос | Проверяемый результат и ограничение |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Практика | Один commit создаёт два dist; review и rollback не знают, какой набор assets был проверен | Как получить два чистых прогона, не смешав установку зависимостей и проверку output | Lockfile/log/manifest route, Node hash script и список входов; нет заявления о запуске этого проекта |
|
||||||
|
| Механизм | Одинаковый Git hash скрывает разные runtime, config или generated data | Где проходит граница функции build и что доказывает manifest SHA-256 | Карта входов, таблица владельцев, контрпримеры contenthash; hash не выдан за подпись или тест приложения |
|
||||||
|
| Полевой разбор | Коллеги получили разные release-файлы и хотят начать с очистки cache | Как по первому differing file отличить dependency, config и generated output | Карточка двух прогонов, дерево диагностики и обратимый путь исправления; CI и production не заявлены |
|
||||||
|
|
||||||
|
- Тон соответствует уровню М2 / 2019: npm 6, webpack 4, Node, проектный
|
||||||
|
npmrc, чистая установка, manifest и diff. В текст не добавлены поздние
|
||||||
|
практики вроде supply-chain attestations, SLO, распределённой трассировки
|
||||||
|
или риторика платформенного техлида.
|
||||||
|
- Во всех трёх текстах держится маршрут «симптом → причина → проверка →
|
||||||
|
действие → ожидаемый результат → ограничение». Конкретные объекты названы:
|
||||||
|
<code>package-lock.json</code>, <code>npm ci</code>, <code>dist</code>,
|
||||||
|
<code>first.manifest</code>, <code>NODE_ENV</code>, runtime chunk и
|
||||||
|
generated banner.
|
||||||
|
- Нет шаблонных призывов «очистить кэш», «закрепить всё» или «сделать CI»
|
||||||
|
без диагностического условия. Каждый такой шаг имеет место в маршруте,
|
||||||
|
владельца и границу вывода.
|
||||||
|
- Draft gate подтвердил основной текст в диапазоне 5 000–15 000 символов:
|
||||||
|
**10 061** для практики, **9 115** для механизма и **8 650** для полевого
|
||||||
|
разбора. В значение не включены title, meta и раздел источников.
|
||||||
|
- В каждой статье есть не менее пяти <code>h2</code>, доступная таблица с
|
||||||
|
<code>caption</code>, <code>thead</code> и <code>scope</code>, оригинальная
|
||||||
|
схема с содержательными <code>alt</code>/<code>figcaption</code>, код,
|
||||||
|
упорядоченный маршрут и не менее двух официальных источников.
|
||||||
|
|
||||||
|
## Проход 3. Визуал и выпуск автономного пакета — пройдено
|
||||||
|
|
||||||
|
- <code>reproducible-build-input-contract-2019.svg</code> ведёт от исходников,
|
||||||
|
зависимостей и среды/команды к чистой установке, build и manifest. Диаграмма
|
||||||
|
подчёркивает, что hash приходит после явного контракта, а не заменяет его.
|
||||||
|
- <code>reproducible-build-hash-boundary-2019.svg</code> отделяет
|
||||||
|
зафиксированные commit/lockfile от runtime, config и generated data. Нижняя
|
||||||
|
развилка не называет расхождение ошибкой сборщика: она ведёт к первому
|
||||||
|
отличающемуся файлу.
|
||||||
|
- <code>reproducible-build-diagnosis-2019.svg</code> показывает две чистые
|
||||||
|
сборки, сравнение входов и явное условие: при расхождении входов маршрут
|
||||||
|
останавливается, при совпадении сравнивается output manifest. Это
|
||||||
|
визуальный маршрут, а не скриншот несуществующего прогона.
|
||||||
|
- В каждом SVG есть <code>title</code>, <code>desc</code>,
|
||||||
|
<code>role="img"</code>, вертикальный <code>viewBox</code>, контрастные
|
||||||
|
карточки и подписи внутри заданной ширины. Нет JavaScript, внешних URL,
|
||||||
|
растровых вложений или анимации. XML-валидатор принял все три файла.
|
||||||
|
- Независимый mobile preflight отрисовал каждый SVG через Sharp в PNG шириной
|
||||||
|
375 px. Первый raster показал мелкие подписи в горизонтальных панелях и
|
||||||
|
обрезание диагностической схемы. Все три визуала пересобраны вертикально;
|
||||||
|
повторный рендер не показал обрезания, наложения или горизонтального выхода.
|
||||||
|
Это проверка статичных изображений, а не browser screenshot или
|
||||||
|
Accessibility tree.
|
||||||
|
- После подключения registry strict audit и production build пройдены. Ручной
|
||||||
|
browser-review поведения страницы остаётся отдельной выпускной проверкой и
|
||||||
|
не заявлен как выполненный.
|
||||||
|
|
||||||
|
## Выполненные команды
|
||||||
|
|
||||||
|
<pre><code>cd web
|
||||||
|
node --check scripts/upgrade-2019-11.mjs
|
||||||
|
npm run audit:draft -- scripts/upgrade-2019-11.mjs
|
||||||
|
node scripts/upgrade-2019-11.mjs --run-fixture
|
||||||
|
xmllint --noout \
|
||||||
|
public/assets/editorial/2019/reproducible-build-input-contract-2019.svg \
|
||||||
|
public/assets/editorial/2019/reproducible-build-hash-boundary-2019.svg \
|
||||||
|
public/assets/editorial/2019/reproducible-build-diagnosis-2019.svg</code></pre>
|
||||||
|
|
||||||
|
Все четыре команды завершились с кодом 0. Draft gate вывел:
|
||||||
|
|
||||||
|
<pre><code>PASS editorial-2019-11-practice-reproducible-builds: 10061 body chars
|
||||||
|
PASS editorial-2019-11-mechanism-reproducible-builds: 9115 body chars
|
||||||
|
PASS editorial-2019-11-field-reproducible-builds: 8650 body chars</code></pre>
|
||||||
|
|
||||||
|
После интеграции strict audit подтвердил: 10 061 / 9 115 / 8 650 знаков,
|
||||||
|
по одному figure и table, 3 / 1 / 1 code example. <code>npm run build</code>
|
||||||
|
завершился с кодом 0 и сгенерировал 374 статические страницы.
|
||||||
|
|
||||||
|
Выпусковой вердикт: **тройное ревью пройдено, пакет принят к публикации**.
|
||||||
|
<code>articles.json</code> не менялся; registry заменяет только редакционные
|
||||||
|
поля по stable slug. Production build и raster preflight не подменяют ручную
|
||||||
|
проверку поведения в браузере.
|
||||||
@@ -17,6 +17,7 @@ import { revisions as july2019Revisions } from '../scripts/upgrade-2019-07.mjs';
|
|||||||
import { revisions as august2019Revisions } from '../scripts/upgrade-2019-08.mjs';
|
import { revisions as august2019Revisions } from '../scripts/upgrade-2019-08.mjs';
|
||||||
import { revisions as september2019Revisions } from '../scripts/upgrade-2019-09.mjs';
|
import { revisions as september2019Revisions } from '../scripts/upgrade-2019-09.mjs';
|
||||||
import { revisions as october2019Revisions } from '../scripts/upgrade-2019-10.mjs';
|
import { revisions as october2019Revisions } from '../scripts/upgrade-2019-10.mjs';
|
||||||
|
import { revisions as november2019Revisions } from '../scripts/upgrade-2019-11.mjs';
|
||||||
|
|
||||||
// This layer replaces archived source entries without losing their stable slug and date.
|
// This layer replaces archived source entries without losing their stable slug and date.
|
||||||
export const editorialRevisions = [
|
export const editorialRevisions = [
|
||||||
@@ -39,4 +40,5 @@ export const editorialRevisions = [
|
|||||||
...august2019Revisions,
|
...august2019Revisions,
|
||||||
...september2019Revisions,
|
...september2019Revisions,
|
||||||
...october2019Revisions,
|
...october2019Revisions,
|
||||||
|
...november2019Revisions,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 1240" role="img" aria-labelledby="title desc">
|
||||||
|
<title id="title">Диагностика различающихся чистых сборок</title>
|
||||||
|
<desc id="desc">Вертикальная схема начинает с двух чистых запусков, сравнивает их входы, затем разветвляется к регистрации различия либо к сравнению output manifest и первому отличающемуся файлу.</desc>
|
||||||
|
<defs>
|
||||||
|
<marker id="arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse">
|
||||||
|
<path d="M0 0L10 5L0 10z" fill="#60758d"/>
|
||||||
|
</marker>
|
||||||
|
<marker id="bad-arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse">
|
||||||
|
<path d="M0 0L10 5L0 10z" fill="#c54646"/>
|
||||||
|
</marker>
|
||||||
|
</defs>
|
||||||
|
<rect width="720" height="1240" fill="#f8fafc"/>
|
||||||
|
<rect x="42" y="34" width="636" height="112" rx="18" fill="#17324d"/>
|
||||||
|
<text x="360" y="80" text-anchor="middle" fill="#ffffff" font-family="Arial, sans-serif" font-size="28" font-weight="700">Две чистые сборки: входы, затем bundle</text>
|
||||||
|
<text x="360" y="116" text-anchor="middle" fill="#d8e7f3" font-family="Arial, sans-serif" font-size="21">Сначала доказываем условия сравнения.</text>
|
||||||
|
|
||||||
|
<rect x="74" y="190" width="572" height="122" rx="18" fill="#e9f3ff" stroke="#3182ce" stroke-width="3"/>
|
||||||
|
<text x="360" y="234" text-anchor="middle" fill="#17324d" font-family="Arial, sans-serif" font-size="27" font-weight="700">Прогон A</text>
|
||||||
|
<text x="360" y="272" text-anchor="middle" fill="#425d73" font-family="Arial, sans-serif" font-size="21">npm ci → build → first manifest</text>
|
||||||
|
<path d="M360 312v36" stroke="#60758d" stroke-width="5" marker-end="url(#arrow)"/>
|
||||||
|
|
||||||
|
<rect x="74" y="356" width="572" height="122" rx="18" fill="#e9f3ff" stroke="#3182ce" stroke-width="3"/>
|
||||||
|
<text x="360" y="400" text-anchor="middle" fill="#17324d" font-family="Arial, sans-serif" font-size="27" font-weight="700">Прогон B</text>
|
||||||
|
<text x="360" y="438" text-anchor="middle" fill="#425d73" font-family="Arial, sans-serif" font-size="21">npm ci → build → second manifest</text>
|
||||||
|
<path d="M360 478v36" stroke="#60758d" stroke-width="5" marker-end="url(#arrow)"/>
|
||||||
|
|
||||||
|
<rect x="74" y="522" width="572" height="148" rx="18" fill="#f3ecff" stroke="#805ad5" stroke-width="3"/>
|
||||||
|
<text x="360" y="566" text-anchor="middle" fill="#2d2360" font-family="Arial, sans-serif" font-size="27" font-weight="700">Сравнить входы</text>
|
||||||
|
<text x="360" y="604" text-anchor="middle" fill="#4d3b78" font-family="Arial, sans-serif" font-size="21">commit, lockfile hash, Node, npm и команда</text>
|
||||||
|
<text x="360" y="634" text-anchor="middle" fill="#4d3b78" font-family="Arial, sans-serif" font-size="20">любая разница останавливает сравнение output</text>
|
||||||
|
<path d="M360 670v36" stroke="#60758d" stroke-width="5" marker-end="url(#arrow)"/>
|
||||||
|
|
||||||
|
<rect x="74" y="714" width="572" height="200" rx="18" fill="#ffffff" stroke="#805ad5" stroke-width="3"/>
|
||||||
|
<text x="360" y="758" text-anchor="middle" fill="#2d2360" font-family="Arial, sans-serif" font-size="27" font-weight="700">Входы совпали?</text>
|
||||||
|
<rect x="102" y="786" width="516" height="48" rx="12" fill="#fff0f0" stroke="#c54646" stroke-width="2"/>
|
||||||
|
<text x="360" y="818" text-anchor="middle" fill="#a12f2f" font-family="Arial, sans-serif" font-size="20" font-weight="700">Нет → записать различие и повторить условия</text>
|
||||||
|
<rect x="102" y="850" width="516" height="48" rx="12" fill="#e8fff3" stroke="#279665" stroke-width="2"/>
|
||||||
|
<text x="360" y="882" text-anchor="middle" fill="#145a3c" font-family="Arial, sans-serif" font-size="20" font-weight="700">Да → сравнить полный output manifest</text>
|
||||||
|
<path d="M360 914v36" stroke="#c54646" stroke-width="5" stroke-dasharray="12 10" marker-end="url(#bad-arrow)"/>
|
||||||
|
|
||||||
|
<rect x="74" y="958" width="572" height="118" rx="18" fill="#fff7ed" stroke="#dd6b20" stroke-width="3"/>
|
||||||
|
<text x="360" y="994" text-anchor="middle" fill="#7c2d12" font-family="Arial, sans-serif" font-size="21" font-weight="700">Если manifest различается:</text>
|
||||||
|
<text x="360" y="1030" text-anchor="middle" fill="#7c2d12" font-family="Arial, sans-serif" font-size="26" font-weight="700">первый differing file</text>
|
||||||
|
<text x="360" y="1060" text-anchor="middle" fill="#9a4c16" font-family="Arial, sans-serif" font-size="20">его bytes ведут к конкретному входу</text>
|
||||||
|
<text x="360" y="1142" text-anchor="middle" fill="#425d73" font-family="Arial, sans-serif" font-size="21">Не начинать с cache: сначала наблюдение.</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.8 KiB |
@@ -0,0 +1,45 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 1160" role="img" aria-labelledby="title desc">
|
||||||
|
<title id="title">Граница lockfile и остальных входов сборки</title>
|
||||||
|
<desc id="desc">Вертикальная схема показывает, что commit и lockfile фиксируются в репозитории, а runtime и config нужно назвать отдельно до вычисления manifest и диагностики расхождения.</desc>
|
||||||
|
<defs>
|
||||||
|
<marker id="arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse">
|
||||||
|
<path d="M0 0L10 5L0 10z" fill="#60758d"/>
|
||||||
|
</marker>
|
||||||
|
<marker id="bad-arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse">
|
||||||
|
<path d="M0 0L10 5L0 10z" fill="#c54646"/>
|
||||||
|
</marker>
|
||||||
|
</defs>
|
||||||
|
<rect width="720" height="1160" fill="#f8fafc"/>
|
||||||
|
<rect x="42" y="34" width="636" height="112" rx="18" fill="#17324d"/>
|
||||||
|
<text x="360" y="80" text-anchor="middle" fill="#ffffff" font-family="Arial, sans-serif" font-size="28" font-weight="700">Lockfile не закрывает все входы</text>
|
||||||
|
<text x="360" y="116" text-anchor="middle" fill="#d8e7f3" font-family="Arial, sans-serif" font-size="21">Фиксируем репозиторий и называем внешнюю среду.</text>
|
||||||
|
|
||||||
|
<rect x="74" y="190" width="572" height="126" rx="18" fill="#e8fff3" stroke="#279665" stroke-width="3"/>
|
||||||
|
<text x="360" y="234" text-anchor="middle" fill="#145a3c" font-family="Arial, sans-serif" font-size="27" font-weight="700">Зафиксировано в репозитории</text>
|
||||||
|
<text x="360" y="272" text-anchor="middle" fill="#24684b" font-family="Arial, sans-serif" font-size="21">commit и package-lock</text>
|
||||||
|
<text x="360" y="300" text-anchor="middle" fill="#24684b" font-family="Arial, sans-serif" font-size="20">исходники и разрешённое дерево пакетов</text>
|
||||||
|
<path d="M360 316v38" stroke="#60758d" stroke-width="5" marker-end="url(#arrow)"/>
|
||||||
|
|
||||||
|
<rect x="74" y="362" width="572" height="140" rx="18" fill="#fff7ed" stroke="#dd6b20" stroke-width="3"/>
|
||||||
|
<text x="360" y="406" text-anchor="middle" fill="#7c2d12" font-family="Arial, sans-serif" font-size="27" font-weight="700">Нужно назвать отдельно</text>
|
||||||
|
<text x="360" y="444" text-anchor="middle" fill="#9a4c16" font-family="Arial, sans-serif" font-size="21">runtime, config, env и время</text>
|
||||||
|
<text x="360" y="474" text-anchor="middle" fill="#9a4c16" font-family="Arial, sans-serif" font-size="20">иначе одинаковый lockfile ничего не доказывает</text>
|
||||||
|
<path d="M360 502v38" stroke="#60758d" stroke-width="5" marker-end="url(#arrow)"/>
|
||||||
|
|
||||||
|
<rect x="74" y="548" width="572" height="126" rx="18" fill="#f3ecff" stroke="#805ad5" stroke-width="3"/>
|
||||||
|
<text x="360" y="592" text-anchor="middle" fill="#2d2360" font-family="Arial, sans-serif" font-size="27" font-weight="700">build(inputs)</text>
|
||||||
|
<text x="360" y="630" text-anchor="middle" fill="#4d3b78" font-family="Arial, sans-serif" font-size="21">webpack и plugins читают только названные входы</text>
|
||||||
|
<text x="360" y="658" text-anchor="middle" fill="#4d3b78" font-family="Arial, sans-serif" font-size="20">неназванный вход остаётся риском</text>
|
||||||
|
<path d="M360 674v38" stroke="#60758d" stroke-width="5" marker-end="url(#arrow)"/>
|
||||||
|
|
||||||
|
<rect x="74" y="720" width="572" height="118" rx="18" fill="#e9f3ff" stroke="#3182ce" stroke-width="3"/>
|
||||||
|
<text x="360" y="764" text-anchor="middle" fill="#17324d" font-family="Arial, sans-serif" font-size="27" font-weight="700">manifest SHA-256</text>
|
||||||
|
<text x="360" y="802" text-anchor="middle" fill="#425d73" font-family="Arial, sans-serif" font-size="21">сортируем путь и digest каждого файла</text>
|
||||||
|
<path d="M360 838v38" stroke="#c54646" stroke-width="5" stroke-dasharray="12 10" marker-end="url(#bad-arrow)"/>
|
||||||
|
|
||||||
|
<rect x="74" y="884" width="572" height="132" rx="18" fill="#fff0f0" stroke="#c54646" stroke-width="3"/>
|
||||||
|
<text x="360" y="930" text-anchor="middle" fill="#a12f2f" font-family="Arial, sans-serif" font-size="27" font-weight="700">Manifest различается?</text>
|
||||||
|
<text x="360" y="968" text-anchor="middle" fill="#a12f2f" font-family="Arial, sans-serif" font-size="21">сначала открыть первый differing file</text>
|
||||||
|
<text x="360" y="996" text-anchor="middle" fill="#a12f2f" font-family="Arial, sans-serif" font-size="20">не начинать расследование с очистки cache</text>
|
||||||
|
<text x="360" y="1102" text-anchor="middle" fill="#425d73" font-family="Arial, sans-serif" font-size="21">Хеш фиксирует факт, причина живёт во входах.</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.8 KiB |
@@ -0,0 +1,45 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 1280" role="img" aria-labelledby="title desc">
|
||||||
|
<title id="title">Контракт входов воспроизводимой сборки</title>
|
||||||
|
<desc id="desc">Вертикальная схема ведёт от зафиксированных исходников, зависимостей и среды к чистой установке, сборке и сортированному manifest с хешами файлов.</desc>
|
||||||
|
<defs>
|
||||||
|
<marker id="arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse">
|
||||||
|
<path d="M0 0L10 5L0 10z" fill="#60758d"/>
|
||||||
|
</marker>
|
||||||
|
</defs>
|
||||||
|
<rect width="720" height="1280" fill="#f8fafc"/>
|
||||||
|
<rect x="42" y="34" width="636" height="112" rx="18" fill="#17324d"/>
|
||||||
|
<text x="360" y="80" text-anchor="middle" fill="#ffffff" font-family="Arial, sans-serif" font-size="28" font-weight="700">Сначала входы, затем output</text>
|
||||||
|
<text x="360" y="116" text-anchor="middle" fill="#d8e7f3" font-family="Arial, sans-serif" font-size="21">Одинаковая команда не отменяет контракт среды.</text>
|
||||||
|
|
||||||
|
<rect x="74" y="190" width="572" height="118" rx="18" fill="#e9f3ff" stroke="#3182ce" stroke-width="3"/>
|
||||||
|
<text x="360" y="234" text-anchor="middle" fill="#17324d" font-family="Arial, sans-serif" font-size="27" font-weight="700">1. Исходный код</text>
|
||||||
|
<text x="360" y="272" text-anchor="middle" fill="#425d73" font-family="Arial, sans-serif" font-size="21">git commit задаёт точное дерево файлов</text>
|
||||||
|
<path d="M360 308v36" stroke="#60758d" stroke-width="5" marker-end="url(#arrow)"/>
|
||||||
|
|
||||||
|
<rect x="74" y="352" width="572" height="118" rx="18" fill="#edf7ff" stroke="#3182ce" stroke-width="3"/>
|
||||||
|
<text x="360" y="396" text-anchor="middle" fill="#17324d" font-family="Arial, sans-serif" font-size="27" font-weight="700">2. Зависимости</text>
|
||||||
|
<text x="360" y="434" text-anchor="middle" fill="#425d73" font-family="Arial, sans-serif" font-size="21">package-lock плюс чистый npm ci</text>
|
||||||
|
<path d="M360 470v36" stroke="#60758d" stroke-width="5" marker-end="url(#arrow)"/>
|
||||||
|
|
||||||
|
<rect x="74" y="514" width="572" height="140" rx="18" fill="#fff7ed" stroke="#dd6b20" stroke-width="3"/>
|
||||||
|
<text x="360" y="558" text-anchor="middle" fill="#17324d" font-family="Arial, sans-serif" font-size="27" font-weight="700">3. Среда и команда</text>
|
||||||
|
<text x="360" y="596" text-anchor="middle" fill="#425d73" font-family="Arial, sans-serif" font-size="21">Node, npm, mode и build-флаги записаны явно</text>
|
||||||
|
<text x="360" y="626" text-anchor="middle" fill="#425d73" font-family="Arial, sans-serif" font-size="20">время и случайность — отдельные входы</text>
|
||||||
|
<path d="M360 654v36" stroke="#60758d" stroke-width="5" marker-end="url(#arrow)"/>
|
||||||
|
|
||||||
|
<rect x="74" y="698" width="572" height="126" rx="18" fill="#f3ecff" stroke="#805ad5" stroke-width="3"/>
|
||||||
|
<text x="360" y="742" text-anchor="middle" fill="#2d2360" font-family="Arial, sans-serif" font-size="27" font-weight="700">Чистая установка</text>
|
||||||
|
<text x="360" y="780" text-anchor="middle" fill="#4d3b78" font-family="Arial, sans-serif" font-size="21">не переписывает manifest и lockfile</text>
|
||||||
|
<text x="360" y="808" text-anchor="middle" fill="#4d3b78" font-family="Arial, sans-serif" font-size="20">перед запуском build</text>
|
||||||
|
<path d="M360 824v36" stroke="#60758d" stroke-width="5" marker-end="url(#arrow)"/>
|
||||||
|
|
||||||
|
<rect x="74" y="868" width="572" height="118" rx="18" fill="#f3ecff" stroke="#805ad5" stroke-width="3"/>
|
||||||
|
<text x="360" y="912" text-anchor="middle" fill="#2d2360" font-family="Arial, sans-serif" font-size="27" font-weight="700">Сборка</text>
|
||||||
|
<text x="360" y="950" text-anchor="middle" fill="#4d3b78" font-family="Arial, sans-serif" font-size="21">производит deployable files</text>
|
||||||
|
<path d="M360 986v36" stroke="#60758d" stroke-width="5" marker-end="url(#arrow)"/>
|
||||||
|
|
||||||
|
<rect x="58" y="1030" width="604" height="128" rx="18" fill="#e8fff3" stroke="#279665" stroke-width="3"/>
|
||||||
|
<text x="360" y="1076" text-anchor="middle" fill="#145a3c" font-family="Arial, sans-serif" font-size="25" font-weight="700">Сортированный manifest SHA-256</text>
|
||||||
|
<text x="360" y="1114" text-anchor="middle" fill="#24684b" font-family="Arial, sans-serif" font-size="21">полный путь и хеш каждого deployable file</text>
|
||||||
|
<text x="360" y="1220" text-anchor="middle" fill="#425d73" font-family="Arial, sans-serif" font-size="21">Сравниваем весь список, а не один bundle.</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.7 KiB |
@@ -0,0 +1,437 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value)
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll("'", ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
function paragraph(text) {
|
||||||
|
return '<p>' + text + '</p>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function heading(text) {
|
||||||
|
return '<h2>' + text + '</h2>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function codeBlock(lines) {
|
||||||
|
const code = Array.isArray(lines) ? lines.join('\n') : String(lines);
|
||||||
|
return '<pre><code>' + escapeHtml(code.trim()) + '</code></pre>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function figure(src, alt, caption) {
|
||||||
|
return '<figure><img src="' + src + '" alt="' + alt + '" loading="lazy" /><figcaption>' + caption + '</figcaption></figure>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function orderedList(items) {
|
||||||
|
return '<ol>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ol>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function dataTable(caption, headers, rows) {
|
||||||
|
const head = '<thead><tr>' + headers.map((header) => '<th scope="col">' + header + '</th>').join('') + '</tr></thead>';
|
||||||
|
const body = '<tbody>' + rows.map((row) => '<tr>' + row.map((cell) => '<td>' + cell + '</td>').join('') + '</tr>').join('') + '</tbody>';
|
||||||
|
return '<div class="table-scroll"><table><caption>' + caption + '</caption>' + head + body + '</table></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceList(items) {
|
||||||
|
return '<ul>' + items.map((item) => '<li><a href="' + item.url + '" target="_blank" rel="noopener noreferrer">' + item.title + '</a> — ' + item.note + '</li>').join('') + '</ul>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function visibleText(html) {
|
||||||
|
return html
|
||||||
|
.replace(/<[^>]*>/g, ' ')
|
||||||
|
.replaceAll(' ', ' ')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll(''', "'")
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function bodyText(html) {
|
||||||
|
return visibleText(html.replace(/<h2>Проверяемые источники<\/h2>[\s\S]*?(?=<h2>|$)/, ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRevision(meta, bodyParts, sources) {
|
||||||
|
const bodyHtml = bodyParts.join('\n');
|
||||||
|
const bodyLength = bodyText(bodyHtml).length;
|
||||||
|
|
||||||
|
if (bodyLength < 5000 || bodyLength > 15000) {
|
||||||
|
throw new Error(meta.slug + ': body length must be 5000–15000, got ' + bodyLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...meta,
|
||||||
|
contentHtml: [bodyHtml, heading('Проверяемые источники'), sourceList(sources)].join('\n'),
|
||||||
|
bodyLength,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const npmPackageLocks = {
|
||||||
|
title: 'npm 6: package-locks',
|
||||||
|
url: 'https://docs.npmjs.com/cli/v6/configuring-npm/package-locks/',
|
||||||
|
note: '<code>package-lock.json</code> описывает зафиксированное дерево зависимостей; semver-диапазон и транзитивные пакеты без него способны дать другой <code>node_modules</code>',
|
||||||
|
};
|
||||||
|
|
||||||
|
const npmPackageLockJson = {
|
||||||
|
title: 'npm 6: package-lock.json',
|
||||||
|
url: 'https://docs.npmjs.com/cli/v6/configuring-npm/package-lock-json/',
|
||||||
|
note: 'описаны поля <code>version</code>, <code>resolved</code> и <code>integrity</code> и назначение lockfile в корне репозитория',
|
||||||
|
};
|
||||||
|
|
||||||
|
const npmCi = {
|
||||||
|
title: 'npm 6: npm ci',
|
||||||
|
url: 'https://docs.npmjs.com/cli/v6/commands/npm-ci/',
|
||||||
|
note: '<code>npm ci</code> требует существующий lockfile, прекращает работу при расхождении с <code>package.json</code>, удаляет старый <code>node_modules</code> и не меняет manifest или lockfile',
|
||||||
|
};
|
||||||
|
|
||||||
|
const npmConfig = {
|
||||||
|
title: 'npm 6: npm config',
|
||||||
|
url: 'https://docs.npmjs.com/cli/v6/commands/npm-config/',
|
||||||
|
note: 'параметры npm могут прийти из CLI, окружения, npmrc или package.json; источник конфигурации надо записывать рядом с прогоном',
|
||||||
|
};
|
||||||
|
|
||||||
|
const webpackCaching = {
|
||||||
|
title: 'webpack: Caching',
|
||||||
|
url: 'https://webpack.js.org/guides/caching/',
|
||||||
|
note: '<code>[contenthash]</code> связан с содержимым asset; guide отдельно разбирает влияние runtime и module IDs на имена файлов',
|
||||||
|
};
|
||||||
|
|
||||||
|
const nodeCrypto = {
|
||||||
|
title: 'Node.js: Crypto',
|
||||||
|
url: 'https://nodejs.org/api/crypto.html',
|
||||||
|
note: '<code>createHash()</code> создаёт hash-объект; в фикстуре он применяется к lockfile и каноническому списку файлов, а не выдаётся за подпись release',
|
||||||
|
};
|
||||||
|
|
||||||
|
function sha256(value) {
|
||||||
|
return createHash('sha256').update(value).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function createManifest(files) {
|
||||||
|
return files
|
||||||
|
.map((file) => file.path + ' ' + sha256(file.content))
|
||||||
|
.sort()
|
||||||
|
.join('\n') + '\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
function runHashFixture() {
|
||||||
|
const lockfile = [
|
||||||
|
'{',
|
||||||
|
' "name": "p21-fixture",',
|
||||||
|
' "lockfileVersion": 1,',
|
||||||
|
' "dependencies": {',
|
||||||
|
' "left-pad": { "version": "1.3.0", "integrity": "sha512-fixture" }',
|
||||||
|
' }',
|
||||||
|
'}',
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
const firstFiles = [
|
||||||
|
{ path: 'dist/main.js', content: 'console.log("fixed input");\n' },
|
||||||
|
{ path: 'dist/index.html', content: '<script src="main.js"></script>\n' },
|
||||||
|
];
|
||||||
|
const secondFiles = [
|
||||||
|
{ path: 'dist/index.html', content: '<script src="main.js"></script>\n' },
|
||||||
|
{ path: 'dist/main.js', content: 'console.log("fixed input");\n' },
|
||||||
|
];
|
||||||
|
const changedFiles = [
|
||||||
|
{ path: 'dist/main.js', content: 'console.log("changed input");\n' },
|
||||||
|
{ path: 'dist/index.html', content: '<script src="main.js"></script>\n' },
|
||||||
|
];
|
||||||
|
const firstHash = sha256(createManifest(firstFiles));
|
||||||
|
const secondHash = sha256(createManifest(secondFiles));
|
||||||
|
const changedHash = sha256(createManifest(changedFiles));
|
||||||
|
|
||||||
|
if (firstHash !== secondHash || firstHash === changedHash) {
|
||||||
|
throw new Error('hash fixture did not distinguish identical and changed manifests');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
fixtureOnly: true,
|
||||||
|
lockfileSha256: sha256(lockfile),
|
||||||
|
firstManifestSha256: firstHash,
|
||||||
|
secondManifestSha256: secondHash,
|
||||||
|
changedManifestSha256: changedHash,
|
||||||
|
checks: {
|
||||||
|
sameEntriesDifferentOrder: firstHash === secondHash,
|
||||||
|
changedFileChangesManifest: firstHash !== changedHash,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const proofManifestScript = [
|
||||||
|
'// 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');",
|
||||||
|
'}',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const practiceArticle = createRevision(
|
||||||
|
{
|
||||||
|
slug: 'editorial-2019-11-practice-reproducible-builds',
|
||||||
|
title: 'Один commit — два dist: как собрать доказательство, а не спор',
|
||||||
|
categories: ['JavaScript', 'npm', 'Webpack', 'Сборка'],
|
||||||
|
cover: '/assets/editorial/2019/reproducible-build-input-contract-2019.svg',
|
||||||
|
excerpt: 'Одинаковый commit выпустил разные файлы. Фиксируем lockfile, runtime, конфигурацию и список SHA-256, чтобы найти меняющийся вход, а не очищать cache наугад.',
|
||||||
|
readingMinutes: 12,
|
||||||
|
},
|
||||||
|
[
|
||||||
|
paragraph('Симптом неприятный и дорогой: один commit собирают два разработчика, а в <code>dist</code> разные имена файлов или разные байты. На первом шаге это часто называют «кэшем webpack» и запускают сборку ещё раз. Цена такого лечения выше лишних минут: в review нельзя понять, что именно уйдёт в релиз, а исправление на одной машине не становится проверяемым для другой. Проблема не в самом хеше. Хеш честно сообщает, что в цепочку вошли разные данные или разный порядок работы. Задача — превратить это сообщение в короткое расследование.'),
|
||||||
|
paragraph('Для проекта на npm и webpack 2019 года не нужно строить абстрактную платформу. Надо договориться о входах: исходный commit, <code>package-lock.json</code>, версия Node и npm, команда, важные параметры окружения и конфигурация сборщика. Затем два раза собрать в отдельной копии дерева и сравнить не названия «на глаз», а канонический список файлов с SHA-256. Такой список не доказывает, что release безопасен. Он отвечает на более узкий вопрос: одинаковый ли output у записанных входов.'),
|
||||||
|
heading('Сначала отделяем сборку от окружения разработчика'),
|
||||||
|
paragraph('У обычного <code>npm install</code> есть законное право обновить дерево зависимостей и записать lockfile. В рабочем цикле это удобно, но при проверке повторяемости смешивает два действия: разрешение версий и установку уже разрешённого дерева. Для контрольного прогона нужен другой контракт. npm 6 описывает <code>npm ci</code> как чистую установку: команда требует lockfile, прекращает работу при расхождении с <code>package.json</code>, удаляет прежний <code>node_modules</code> и не пишет manifest или lockfile. Это не делает все файлы мира одинаковыми, но закрывает случайную переустановку транзитивной зависимости.'),
|
||||||
|
paragraph('Чистая установка не означает «запустить на домашней машине без записи контекста». npm берёт настройки из флагов, переменных среды, <code>.npmrc</code>, пользовательского и глобального npmrc. Версия runtime влияет на транспиляцию, native-пакеты и скрипты жизненного цикла. Поэтому журнал хранит не секреты и не полный дамп окружения, а минимум, который позволяет повторить путь: hash commit, версию Node/npm, выбранный registry без токена, hash lockfile, команду build и SHA-256 output-manifest. Если в логе есть пароль, его нельзя считать доказательством: он уже инцидент.'),
|
||||||
|
dataTable(
|
||||||
|
'Минимальные входы контрольной сборки',
|
||||||
|
['Вход', 'Почему может менять результат', 'Что записать', 'Чего не делать'],
|
||||||
|
[
|
||||||
|
['Исходный код', 'Другой commit, незакоммиченный файл или generated source меняет bundle', '<code>git rev-parse HEAD</code> и состояние дерева', 'Не писать «актуальный master» вместо точного hash'],
|
||||||
|
['Дерево пакетов', 'Semver-диапазон и транзитивный пакет могут разрешиться иначе', 'SHA-256 <code>package-lock.json</code> и факт <code>npm ci</code>', 'Не перегенерировать lockfile во время сравнения'],
|
||||||
|
['Node, npm и npmrc', 'Runtime и параметры установки участвуют в сборочных скриптах', 'Только версии и безопасные значения registry/config', 'Не копировать токены, proxy-пароли или весь <code>env</code>'],
|
||||||
|
['Команда и config', 'Другой mode, public path или define-переменная меняют bytes', 'Полную команду и hash конфигурации', 'Не подменять production-команду похожей локальной'],
|
||||||
|
['Output', 'Имя файла не доказывает равенство всех файлов', 'Отсортированный manifest SHA-256', 'Не сравнивать только один главный bundle'],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
heading('Фикстура: журнал, lockfile и manifest'),
|
||||||
|
paragraph('Ниже не «скрипт для CI» и не обещание, что он уже работал в production. Это маленькая фикстура для disposable-копии репозитория. Она сначала сохраняет наблюдаемые входы, потом выполняет ровно ту сборочную команду, которую проект использует, и только после неё создаёт manifest. Команду <code>npm ci</code> нельзя подменять <code>npm install</code>: во втором случае можно одновременно искать ошибку и незаметно менять объект расследования. Если проект не поддерживает npm 6 или применяет Yarn, нужно выбрать эквивалент чистой установки из документации своего менеджера.'),
|
||||||
|
codeBlock([
|
||||||
|
'# Запускать в отдельной копии рабочего дерева, не в папке с незаписанной работой.',
|
||||||
|
'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',
|
||||||
|
]),
|
||||||
|
paragraph('Скрипт <code>tools/proof-manifest.mjs</code> ниже читает байты каждого файла, сортирует относительные пути и печатает digest вместе с путём. Сортировка важна: файловая система не обязана возвращать каталог в одном и том же порядке. Manifest превращает набор из десятков assets в один diff, который можно показать коллеге. При расхождении не начинайте с поиска «правильного» хеша. Сначала сравните сами manifest: если отличается один <code>runtime</code>-файл, путь расследования иной, чем когда changed half of <code>node_modules</code> или HTML содержит момент времени.'),
|
||||||
|
codeBlock(proofManifestScript),
|
||||||
|
heading('Два прогона должны различаться только именем журнала'),
|
||||||
|
paragraph('Сделайте второй прогон с тем же commit и тем же lockfile, но в свежей рабочей копии. Он должен получить собственные <code>second.log</code> и <code>second.manifest</code>. Нельзя брать уже построенный <code>dist</code> как «второй результат»: тогда мы проверим сохранность каталога, а не повторяемость сборки. Перед сравнением полезно проверить, что оба manifest содержат одинаковое число строк. Это не заменяет hash, но быстро ловит случай, когда в одном прогоне вообще не возник CSS, source map или chunk.'),
|
||||||
|
codeBlock([
|
||||||
|
'# Во второй чистой копии с тем же 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',
|
||||||
|
]),
|
||||||
|
paragraph('Если manifest совпал, фиксируем результат узко: «в этих двух чистых прогонах с записанными входами SHA-256 manifest совпал». Не надо расширять вывод до «сборка всегда воспроизводима»: не проверены другая ОС, другой CPU, другой registry и будущие версии инструментов. Если manifest различается, журнал позволяет сначала сравнить версии и lockfile, потом команды и config, и только после этого идти в webpack output. Простой порядок избавляет от бесконечного удаления cache.'),
|
||||||
|
figure('/assets/editorial/2019/reproducible-build-input-contract-2019.svg', 'Схема контрольной сборки: commit, package-lock, версии Node и npm, безопасная конфигурация и команда поступают в чистую установку и webpack; результатом является отсортированный manifest SHA-256', 'Повторяемость начинается не с хеша bundle, а с явного списка входов. Hash manifest нужен, чтобы сравнить весь output одним проверяемым артефактом.'),
|
||||||
|
heading('Как читать расхождение без гадания'),
|
||||||
|
paragraph('Первый тип расхождения — разный lockfile hash или разные версии npm. Здесь нет смысла обсуждать webpack: установка получила разные входы. Второй — lockfile одинаков, а в журнале другая команда, <code>NODE_ENV</code>, <code>--mode</code> или project npmrc. Это граница конфигурации. Третий — входы записаны одинаково, но расходятся assets. Тогда смотрим на самый ранний различающийся файл и на его содержимое. В webpack 4 имя с <code>[contenthash]</code> связано с content asset, но runtime, manifest и идентификаторы модулей могут сделать отличие шире, чем изменение одного исходника. Не делайте вывод по одному filename.'),
|
||||||
|
paragraph('Особый класс — время, путь, locale и случайность. Banner-плагин может вставить дату, генератор документации — абсолютный путь, а код — случайный идентификатор. Такой output не лечится новой фиксацией npm-пакета: надо найти генератор и решить, должен ли он получить фиксированное значение, исключаться из сравнения по явному правилу или жить вне deployable artifact. Исключение нельзя молча сделать через <code>grep -v</code>. Его нужно назвать в контракте и объяснить, почему файл не влияет на поставку.'),
|
||||||
|
heading('Маршрут внедрения в старый webpack-проект'),
|
||||||
|
orderedList([
|
||||||
|
'Выбрать одну реальную команду сборки и отдельную копию дерева. Записать commit, runtime и hash lockfile до установки.',
|
||||||
|
'Заменить для контрольного прогона обычный install на <code>npm ci</code>; если команда падает на рассинхронизации, исправить manifest/lockfile отдельным изменением.',
|
||||||
|
'Добавить manifest по файлам deployable-каталога. Сначала хранить его рядом с журналом, не в public assets.',
|
||||||
|
'Повторить сборку в новой копии и сравнить manifest. При различии классифицировать вход: dependency, config, environment или generated output.',
|
||||||
|
'Внести минимальное исправление одной причины. Повторить два прогона и оставить рядом diff/лог без секретов.',
|
||||||
|
'Только после стабильного fixture решать, где этот контроль будет жить дальше: локальная release-инструкция, отдельный job или проверка перед выкладкой.',
|
||||||
|
]),
|
||||||
|
heading('Что считается готовым, а что нет'),
|
||||||
|
paragraph('Готовый первый шаг — не красивый badge и не обещание «у нас детерминированный webpack». Это папка доказательства, из которой другой разработчик понимает exact commit, lockfile hash, runtime, команду и отличие либо совпадение manifest. Для наследуемого проекта этого достаточно, чтобы следующая ошибка не начиналась с памяти о том, как однажды помогла очистка cache. Следующий шаг определяется причиной: зафиксировать версию инструмента, убрать timestamp, оформить project npmrc или разделить runtime chunk. Один symptom не требует сразу переделывать всю цепочку доставки.'),
|
||||||
|
heading('Ограничение результата'),
|
||||||
|
paragraph('Эта практика не выполняла <code>npm ci</code> и webpack для данного архива и не выдаёт sample hash за hash сайта. В модуле пакета есть автономная hash-фикстура: она доказывает только свойство канонического manifest — одинаковые sample files в разном порядке дают один SHA-256, изменённый байт даёт другой. Реальный проект должен получить собственные журнал и manifest на своей версии Node/npm. Именно запись этих артефактов отделяет проверку от убедительного, но пустого рассказа.'),
|
||||||
|
],
|
||||||
|
[npmPackageLocks, npmPackageLockJson, npmCi, npmConfig, webpackCaching, nodeCrypto],
|
||||||
|
);
|
||||||
|
|
||||||
|
const mechanismArticle = createRevision(
|
||||||
|
{
|
||||||
|
slug: 'editorial-2019-11-mechanism-reproducible-builds',
|
||||||
|
title: 'Почему одинаковый commit даёт другой bundle: границы воспроизводимой сборки',
|
||||||
|
categories: ['JavaScript', 'npm', 'Webpack', 'Сборка'],
|
||||||
|
cover: '/assets/editorial/2019/reproducible-build-hash-boundary-2019.svg',
|
||||||
|
excerpt: 'Lockfile фиксирует дерево пакетов, но не время, конфигурацию и поведение plugins. Разбираем, какие входы принадлежат сборке и что именно доказывает SHA-256 manifest.',
|
||||||
|
readingMinutes: 13,
|
||||||
|
},
|
||||||
|
[
|
||||||
|
paragraph('Симптом выглядит нелогично: исходники не менялись, commit тот же, а webpack выдаёт другой <code>main.*.js</code>. Цена ошибки — не только cache miss. Когда команда не знает границы входов, она спорит о случайности вместо того, чтобы показать изменение в байтах и найти владельца. Проблема начинается с слишком короткой модели: «webpack собирает source». На деле bundle зависит от разрешённого дерева пакетов, runtime, конфигурации, командной строки и данных, которые plugins читают во время работы. Одинаковый Git hash не делает эти входы одинаковыми автоматически.'),
|
||||||
|
paragraph('Воспроизводимая сборка в этой заметке — проверяемое свойство конкретной команды: два чистых прогона с зафиксированным набором входов дают одинаковый набор байтов, либо различие явно объяснено. Это не сертификат для всех машин и не замена тестам. Такая формулировка полезна, потому что разрешает действовать: сначала назвать входы, потом сохранить их, потом сравнить output. Если один вход не фиксируется, он становится не мистикой, а точкой контракта.'),
|
||||||
|
heading('Сборка как функция с внешними аргументами'),
|
||||||
|
paragraph('Удобная модель для практики: <code>artifact = build(source, dependencyTree, runtime, config, environment, generatedData)</code>. Git хранит в основном source и часть config. Lockfile хранит dependencyTree, но только если он действительно используется. Runtime — это версия Node, npm и иногда платформенные библиотеки для native dependency. Environment — режим сборки, locale, timezone, path, registry, системные переменные. Generated data — дата в banner, случайный ID, список файлов каталога, ответ внешнего API. Модель не требует заморозить вселенную. Она заставляет для каждого отличившегося байта спросить: какой аргумент его породил?'),
|
||||||
|
dataTable(
|
||||||
|
'Граница входов и наблюдаемый след',
|
||||||
|
['Компонент функции', 'Пример дрейфа', 'Как увидеть', 'Чей следующий шаг'],
|
||||||
|
[
|
||||||
|
['<code>source</code>', 'Незаписанный generated file или другой commit', 'Hash HEAD и <code>git status --short</code>', 'Разработчик фиксирует или исключает generated source по правилам репозитория'],
|
||||||
|
['<code>dependencyTree</code>', 'Новый транзитивный пакет попал под semver-диапазон', 'Hash package-lock и log чистой установки', 'Владелец зависимостей обновляет lockfile отдельным diff'],
|
||||||
|
['<code>runtime</code>', 'Другой Node меняет синтаксическое преобразование или native module', '<code>node --version</code>, <code>npm --version</code>', 'Проект задаёт поддерживаемую версию и способ её получить'],
|
||||||
|
['<code>config</code>', 'Другой mode, publicPath или define-значение', 'Команда, project config и безопасный diff параметров', 'Владелец build config делает параметр явным'],
|
||||||
|
['<code>generatedData</code>', 'Дата, абсолютный путь, порядок чтения каталога, случайный ID', 'Diff конкретного asset и его генератор', 'Владелец генератора фиксирует значение или документирует исключение'],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
paragraph('Таблица не предлагает печатать всё окружение в лог. В конфигурации почти всегда есть секреты, а полный env делает сравнение шумным. Нужен белый список: версии tools, выбранная команда, hash lockfile, registry host без токена, mode и явно поддерживаемые build-переменные. Если после такого списка результат расходится, это не повод перейти к дампу секретов. Это повод сравнить фактический output и раскрыть следующий неназванный вход.'),
|
||||||
|
heading('Lockfile фиксирует разрешение, а не весь процесс'),
|
||||||
|
paragraph('npm отделяет <code>package.json</code> от lockfile не случайно. Manifest говорит, какой диапазон или пакет хочет проект; lockfile описывает конкретное дерево, включая resolved location и integrity. Наличие точной верхней версии в manifest не всегда закрывает транзитивные зависимости. Поэтому для сравнения «два прогона одной сборки» важно не только прочесть <code>dependencies</code>, а проверить, что оба прогона используют один и тот же <code>package-lock.json</code>. npm рекомендует коммитить этот файл именно для того, чтобы команда и системы установки получали одинаковое дерево.'),
|
||||||
|
paragraph('<code>npm ci</code> полезен здесь не скоростью, а отказом от творчества. Он не пытается аккуратно совместить локальный <code>node_modules</code> с новым manifest и не чинит lockfile по ходу проверки. Если <code>package.json</code> и lockfile расходятся, остановка — правильный результат: проверяемого входа нет. Нельзя обходить её удалением lockfile или переходом на <code>npm install</code>. Сначала нужно решить, какое дерево зависимостей проект вообще выбирает, закоммитить этот выбор отдельным diff, а потом вернуться к проверке output.'),
|
||||||
|
heading('Почему filename с contenthash — не весь доказательный набор'),
|
||||||
|
paragraph('Webpack использует <code>[contenthash]</code> как hash содержимого asset, поэтому различное имя bundle — полезная лампа: bytes изменились. Но обратное заключение слишком сильное. Один файл может не сменить имя, а другой asset, HTML, source map или CSS уже расходятся. И наоборот, runtime или идентификаторы модулей способны менять несколько chunk names после малого изменения графа модулей. В guide webpack это разобрано на примере runtime и module IDs: без устойчивых идентификаторов изменение порядка разрешения может сдвинуть hash vendor chunk. Для диагноста это значит: filename — вход в расследование, manifest всех deployable files — доказательство его результата.'),
|
||||||
|
paragraph('Не надо автоматически включать в сравнение всё, что лежит рядом с <code>dist</code>. Source map иногда содержит абсолютные пути. Статистика webpack может быть журналом, а не артефактом. Правильный вопрос: «какие файлы потребляет выкладка или браузер?» Составьте allowlist или директорию deployable output и храните её в build contract. Если source map тоже доставляется пользователю, он входит в manifest. Если нет — исключение должно быть явным, с причиной и владельцем. Молчаливое исключение создаёт ложное совпадение.'),
|
||||||
|
figure('/assets/editorial/2019/reproducible-build-hash-boundary-2019.svg', 'Схема границы: commit и lockfile фиксируют исходники и зависимости, runtime/config/generated data остаются отдельными входами; после build сравнивается hash канонического manifest, а не один filename', 'Lockfile необходим, но не достаточен. Все данные, которые build читает, надо либо фиксировать, либо честно обозначить как источник различия.'),
|
||||||
|
heading('Hash отвечает на точный вопрос'),
|
||||||
|
paragraph('SHA-256 в этом процессе — отпечаток набора уже выбранных байтов. Он не объясняет различие, не заменяет подпись пакета и не проверяет, что bundle работает. Его сила в сравнении: два одинаковых manifest дают одинаковый digest, а один изменённый файл меняет manifest. Чтобы это свойство не испортил порядок каталога, manifest строится из строк <code>sha256 + path</code>, отсортированных по пути. Сначала можно читать diff строк, затем сравнить итоговый hash для короткого отчёта. Если хранить только один digest, вы узнаете, что проблема есть, но потеряете место, где она возникла.'),
|
||||||
|
codeBlock([
|
||||||
|
"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: порядок массива не влияет',
|
||||||
|
]),
|
||||||
|
paragraph('В пакете есть автономный <code>--run-fixture</code> с этим правилом. Он использует sample lockfile и sample bytes, поэтому его выход нельзя назвать результатом npm, webpack или production. Он проверяет только алгоритм: перестановка одинаковых entries даёт тот же digest, изменение байта меняет digest. Для реальной сборки потребуется внешний журнал и manifest после фактической команды. Это ограничение важно: хорошая фикстура делает ровно одно утверждение и не прячется за знакомые слова.'),
|
||||||
|
heading('Как локализовать первую разницу'),
|
||||||
|
orderedList([
|
||||||
|
'Сравнить 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 совпадёт или разойдётся без объяснения.',
|
||||||
|
]),
|
||||||
|
heading('Типовые ложные исправления'),
|
||||||
|
paragraph('Первое ложное исправление — закрепить версию webpack и объявить задачу закрытой. Версия сборщика важна, но date banner или разный mode продолжат менять output. Второе — добавить <code>[contenthash]</code> и сравнивать только имена. Это хорошо для cache policy, но не для полного доказательства. Третье — добавить timestamp в имя журнала и потом случайно включить журнал в manifest. Четвёртое — отключить source map, чтобы hash совпал, хотя map должен поставляться клиенту. В каждом случае результат выглядит спокойнее, но граница продукта изменилась молча.'),
|
||||||
|
heading('Практический предел модели'),
|
||||||
|
paragraph('Две одинаковые локальные сборки не утверждают, что Linux и macOS дадут одинаковые binary dependencies, что registry всегда отдаст те же tarball или что внешняя генерация никогда не изменится. Эти вопросы нужно добавлять по мере реальной стоимости ошибки. Для автора 2019 года полезнее сначала получить один воспроизводимый путь на поддерживаемой среде, чем написать манифест о supply chain, не умея сравнить два <code>dist</code>. Следующий разумный артефакт — короткий runbook с входами и последним результатом, а не ещё один набор флагов webpack.'),
|
||||||
|
],
|
||||||
|
[npmPackageLocks, npmPackageLockJson, npmCi, npmConfig, webpackCaching, nodeCrypto],
|
||||||
|
);
|
||||||
|
|
||||||
|
const fieldArticle = createRevision(
|
||||||
|
{
|
||||||
|
slug: 'editorial-2019-11-field-reproducible-builds',
|
||||||
|
title: 'Разбор: разные release-файлы из одного commit в legacy webpack-проекте',
|
||||||
|
categories: ['JavaScript', 'npm', 'Webpack', 'Сборка'],
|
||||||
|
cover: '/assets/editorial/2019/reproducible-build-diagnosis-2019.svg',
|
||||||
|
excerpt: 'Полевой разбор двух разных dist из одного commit: как не спутать lockfile, режим сборки, runtime и timestamp plugin, а затем оставить короткий след для следующей выкладки.',
|
||||||
|
readingMinutes: 13,
|
||||||
|
},
|
||||||
|
[
|
||||||
|
paragraph('Симптом из поля: разработчик собрал release, коллега повторил тот же commit и получил другой <code>vendors.*.js</code>. Приложение открывается в обоих случаях, поэтому проблему предлагают отложить: «на сервере всё равно соберётся ещё раз». Цена такого решения появляется при rollback и расследовании: неясно, какой именно каталог проверяли, а cache получает два набора assets для одного номера версии. Здесь нельзя лечить исходя из названия файла. Нужно построить цепочку: что было входом, где байты впервые разошлись и какое изменение делает правило явным.'),
|
||||||
|
paragraph('Кейс намеренно небольшой. В нём есть npm 6, webpack 4, legacy plugin и один deployable каталог. Нет реального production-лога, не заявляется запуск CI и не приводится hash настоящего сайта. Вместо этого есть метод, который можно применить в отдельной копии проекта: два чистых прогона, два журнала, два manifest и один минимальный diff. Такой масштаб важен: если сразу обвинить registry, minifier и операционную систему, команда получит много версий истории, но ни одного проверяемого факта.'),
|
||||||
|
heading('Собираем карточку инцидента до исправления'),
|
||||||
|
paragraph('Первая запись должна быть короче issue. Для обоих прогонов нужны: commit, состояние дерева, SHA-256 lockfile, версия Node/npm, команда сборки, ключевые build variables без секретов и manifest. Самый частый пропуск — чистота каталога. Если второй прогон выполняется после первого в том же <code>node_modules</code> и <code>dist</code>, непонятно, какой слой взял данные из прошлого результата. Поэтому берём два worktree или две disposable-копии. Это не бюрократия: <code>npm ci</code> сам удалит <code>node_modules</code>, а независимый каталог защищает от старых generated files.'),
|
||||||
|
dataTable(
|
||||||
|
'Карточка двух прогонов',
|
||||||
|
['Поле', 'Прогон A', 'Прогон B', 'Смысл различия'],
|
||||||
|
[
|
||||||
|
['Commit', '<code>git rev-parse HEAD</code>', '<code>git rev-parse HEAD</code>', 'Любое отличие прекращает сравнение output'],
|
||||||
|
['Lockfile', 'SHA-256 package-lock.json', 'SHA-256 package-lock.json', 'Разный digest означает разные зависимые входы'],
|
||||||
|
['Runtime', 'Node/npm version', 'Node/npm version', 'Разная версия — гипотеза до чтения webpack diff'],
|
||||||
|
['Команда', '<code>npm run build</code> с mode', 'Та же строка команды', 'Mode и env должны быть записаны, а не remembered'],
|
||||||
|
['Manifest', 'SHA-256 каждого deployable file', 'То же представление', 'Diff показывает первый фактический разрыв'],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
paragraph('Таблица не делает два окружения одинаковыми. Она делает отличие наблюдаемым. Например, если lockfile hash различается, нельзя продолжать спор о module id: изменилось dependencyTree. Если все поля равны, но differ только <code>index.html</code>, не нужно обновлять packages: нужно открыть HTML и найти переменную, timestamp или public path. Форма записи экономит время потому, что запрещает перескакивать через предыдущий слой без доказательства.'),
|
||||||
|
heading('Отделяем разные зависимости от разного output'),
|
||||||
|
paragraph('В первом сценарии два <code>package-lock.json</code> отличаются. Причина часто не в том, что разработчик «не сделал npm install». npm lockfile существует именно для фиксации дерева; без него следующий install может выбрать более свежий пакет в допустимом диапазоне, включая транзитивный. Действие: остановить сборочные прогоны, рассмотреть diff manifest и lockfile как отдельное изменение, затем выбрать один tree и снова запустить чистую установку. Нельзя копировать чей-то <code>node_modules</code> в архив: это маскирует проблему, не делает её ревизируемой.'),
|
||||||
|
paragraph('Во втором сценарии lockfile и runtime совпадают, но config нет. Один запуск получил <code>NODE_ENV=production</code>, другой — development, либо webpack config читает переменную без default. Признак — разный набор chunks, source maps или public URL. Действие: сохранить точную команду в package script или release runbook, а требуемые переменные проверять до запуска. Выводить весь <code>process.env</code> не нужно: он может раскрыть секрет. Лучше перечислить одну переменную и её допустимые значения, если именно она меняет output.'),
|
||||||
|
heading('Третий сценарий: lockfile и команда равны, bytes нет'),
|
||||||
|
paragraph('Здесь появляется настоящая диагностическая работа. Сравнение manifest показывает самый ранний отличающийся файл. Допустим, это banner в главном bundle с текущей датой. Чистая установка ничего не изменит: dependencyTree уже одинаков. Нужно решить контракт generated data. Если дата служит только журналу, хранить её в отдельном файле доказательства, а не в deployable asset. Если дата нужна пользователю, считать её частью входа: передавать явно, записывать значение и ожидать разный output для разного release. Плохой выход — «не сравнивать этот файл», не объясняя, почему он участвует в выкладке.'),
|
||||||
|
paragraph('Другой частый след — движение hash всех chunks после малого изменения graph. В webpack 4 runtime может содержать связь chunk/module IDs, а порядок разрешения влияет на эти IDs. Это не доказательство bug в webpack и не повод сразу править optimisation наугад. Сначала на маленьком diff проверить, менялся ли source graph и какой chunk расходится первым. Затем оценить, нужна ли стабилизация module IDs и выделение runtime. Цель не в том, чтобы любой локальный edit сохранял hash vendor файла, а в том, чтобы причина изменения была известна и проверяема.'),
|
||||||
|
figure('/assets/editorial/2019/reproducible-build-diagnosis-2019.svg', 'Диагностическая схема двух чистых прогонов: сравниваются commit, lockfile, runtime и команда; затем сравнивается manifest. Ветка ведёт к dependency, config или generated output, а не к очистке cache.', 'Порядок проверки не сокращает техническую проблему. Он не даёт начать с webpack, пока разные входы ещё не исключены.'),
|
||||||
|
heading('Минимальный fixture без обещаний о CI'),
|
||||||
|
paragraph('Вместо скриншота terminal полезнее маленький fixture. Модуль P21 умеет запустить <code>--run-fixture</code>: он канонически сортирует sample paths, считает SHA-256 и проверяет два свойства. Перестановка входных записей не меняет manifest hash; изменение одного sample bundle меняет его. Fixture не загружает пакет из registry, не выполняет webpack и не создаёт production artifact. Его роль скромная, но честная: правило сравнения можно проверить отдельно от сети и конкретной машины.'),
|
||||||
|
codeBlock([
|
||||||
|
'node web/scripts/upgrade-2019-11.mjs --run-fixture',
|
||||||
|
'',
|
||||||
|
'# Ожидаемая форма результата, не hash настоящего проекта:',
|
||||||
|
'{',
|
||||||
|
' "fixtureOnly": true,',
|
||||||
|
' "checks": {',
|
||||||
|
' "sameEntriesDifferentOrder": true,',
|
||||||
|
' "changedFileChangesManifest": true',
|
||||||
|
' }',
|
||||||
|
'}',
|
||||||
|
]),
|
||||||
|
paragraph('Реальный fixture строится поверх тех же правил, но после реальной команды build. Для двух копий проекта сохраняем <code>first.log</code>, <code>second.log</code>, <code>first.manifest</code> и <code>second.manifest</code>. Логи полезны только вместе с контекстом. Фраза «npm ci завершился успешно» без версии Node, lockfile hash и команды не позволяет повторить даже успешный сценарий. В то же время log не должен хранить auth header, token или полный URL private registry. Проверка сборки не отменяет базовую гигиену секретов.'),
|
||||||
|
heading('Маршрут исправления одной причины'),
|
||||||
|
orderedList([
|
||||||
|
'Взять два новых каталога на одном commit. До установки записать hash lockfile, Node/npm и выбранную build-команду.',
|
||||||
|
'Запустить <code>npm ci</code> в каждом каталоге. Расхождение manifest/package-lock считается отдельной задачей, а не поводом продолжить.',
|
||||||
|
'Собрать и создать отсортированный SHA-256 manifest только для deployable output.',
|
||||||
|
'Сравнить manifest; для первого отличающегося path открыть bytes и его генератор.',
|
||||||
|
'Отнести отличие к одному владельцу: dependency tree, runtime/config или generated data. Зафиксировать только эту причину отдельным diff.',
|
||||||
|
'Повторить два чистых прогона. При совпадении сохранить короткий результат; при расхождении не скрывать файл, а продолжить от следующего earliest diff.',
|
||||||
|
'Определить, где живёт проверка дальше. Она может остаться release checklist, пока нет доказательства, что её нужно автоматизировать.',
|
||||||
|
]),
|
||||||
|
heading('Что не помогло бы в этом кейсе'),
|
||||||
|
paragraph('Удалить cache вручную — полезный санитарный приём, но это не объяснение. Поменять output filename на timestamp — наоборот, гарантированно создаст разные paths. Выполнить <code>npm update</code> перед повторной проверкой — изменит dependencyTree и разрушит исходный эксперимент. Закрепить абсолютную версию одной прямой зависимости — не обязательно остановит транзитивный дрейф без lockfile. Наконец, сравнить только размер <code>main.js</code> — недостаточно: одинаковый размер допускает разные bytes, а HTML, CSS и дополнительные chunks останутся без проверки.'),
|
||||||
|
heading('Критерий закрытия и обратимый шаг'),
|
||||||
|
paragraph('Кейс закрыт не когда manifest «однажды совпал», а когда в repository или release-note есть воспроизводимый маршрут: какие inputs записать, как сделать две чистые установки, где лежит manifest и какое различие считать нормальным. Исправление должно быть обратимо. Например, если project config начинает требовать <code>BUILD_VERSION</code>, добавьте явную ошибку при его отсутствии и document default для локальной разработки, а не встраивайте переменную без следа. Если это решение ломает legacy deploy, можно убрать обязательность одним малым изменением и сохранить fixture как диагноз.'),
|
||||||
|
paragraph('В этом пакете не было реального production прогона и не появилось нового CI job. Есть три подробные статьи, собственные SVG и автономная hash-фикстура. Они дают следующий разговор с проектом: «вот выбранные inputs, вот manifest, вот первый файл различия». Для команды 2019 года это уже сильнее общего совета «закрепите зависимости», потому что совет превратился в действие, журнал и проверяемый предел вывода.'),
|
||||||
|
],
|
||||||
|
[npmPackageLocks, npmPackageLockJson, npmCi, npmConfig, webpackCaching, nodeCrypto],
|
||||||
|
);
|
||||||
|
|
||||||
|
export const revisions = [practiceArticle, mechanismArticle, fieldArticle]
|
||||||
|
.map(({ bodyLength, ...revision }) => revision);
|
||||||
|
|
||||||
|
const isDirectRun = process.argv[1]
|
||||||
|
&& resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
||||||
|
|
||||||
|
if (isDirectRun) {
|
||||||
|
if (process.argv.includes('--print-revisions')) {
|
||||||
|
process.stdout.write(JSON.stringify(revisions, null, 2) + '\n');
|
||||||
|
} else if (process.argv.includes('--run-fixture')) {
|
||||||
|
process.stdout.write(JSON.stringify(runHashFixture(), null, 2) + '\n');
|
||||||
|
} else {
|
||||||
|
process.stderr.write('Usage: node web/scripts/upgrade-2019-11.mjs --print-revisions | --run-fixture\n');
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user