Сборка и зависимости: от исходника до артефакта
Откройте типичный веб-сервис на Node.js и посчитайте строки. Своего кода — 20 тысяч. В node_modules — 40 мегабайт и полторы тысячи пакетов, написанных людьми, которых вы никогда не видели. Примерно то же соотношение в Python, Java и Rust: чужого кода в вашем продукте на порядок больше, чем своего, и он попадает туда автоматически, по транзитивным связям, которые никто не читал целиком.
При этом между «исходники в репозитории» и «работающее приложение» стоит процесс, который обычно воспринимают как чёрный ящик: npm run build, mvn package, docker build. Пока он зелёный, в него не смотрят. Смотреть начинают, когда сборка стала занимать 25 минут, когда она собрала у вас одно, а в CI другое, или когда в отчёте сканера появилась критическая уязвимость в пакете, о существовании которого никто в команде не знал.
Эта глава — про инженерную механику: что именно делает сборщик, зачем нужен lock-файл, как экосистемы разрешают конфликты версий, что значит «воспроизводимая сборка» и как жить с чужим кодом так, чтобы он не жил вашей жизнью. Соседние темы разбираются подробно в других треках: зависимости как вопрос проектирования — в Зависимости и компоненты, образы и реестры — в Контейнерах, безопасность цепочки поставки — в Supply chain, фронтенд-сборщики — в Build tooling.
Что вообще делает сборка
Языки решают задачу по-разному, но стадии одни и те же. Go компилирует в статический бинарь, TypeScript транспилируется в JavaScript и бандлится, Java собирается в jar и грузится JVM — но всюду есть разрешение зависимостей, преобразование исходников, склейка, упаковка и публикация.
+ манифест
+ lock-файл"] --> RES subgraph RES["1. Разрешение зависимостей"] R1["Прочитать lock"] --> R2["Скачать из реестра
или взять из кэша"] --> R3["Проверить контрольные суммы"] end RES --> COMP subgraph COMP["2. Преобразование кода"] C1["Компиляция или транспиляция"] --> C2["Кодогенерация:
protobuf, ORM, DI"] end COMP --> LINK subgraph LINK["3. Склейка"] L1["Линковка статическая
или динамическая"] --> L2["Бандлинг, tree-shaking,
минификация"] end LINK --> PACK subgraph PACK["4. Упаковка"] P1["Артефакт: jar, wheel,
бинарь, tarball"] --> P2["Образ контейнера"] end PACK --> PUB["5. Публикация
в реестр артефактов"] PUB --> DEPLOY(["Деплой: тот же артефакт
во все окружения"]) style DEPLOY fill:#4a7c59,stroke:#2d4a35,color:#fff
Две вещи, которые полезно понять сразу.
Первая: стадия 1 — единственная, где сборка ходит в сеть. Всё остальное — чистое преобразование данных. Именно поэтому кэширование зависимостей даёт самый большой выигрыш по времени, а «сборка без сети» — реалистичная и полезная цель.
Вторая: артефакт собирается один раз. Тот же самый файл или образ, который прошёл тесты на стейджинге, уезжает в прод. Пересборка «того же коммита» для прода — распространённая и опасная практика: вы деплоите не то, что тестировали. Подробнее об этом — в главах CI/CD и Окружения, конфигурация и секреты.
Менеджеры пакетов: манифест против lock-файла
Это центральное различие всей темы, и его путают постоянно.
- Манифест — то, что вы хотите. Пишет человек. Содержит ограничения: «мне нужен HTTP-клиент версии не ниже 2.4, но не следующей мажорной».
- Lock-файл — то, что вы получили. Пишет менеджер пакетов. Содержит точный список: каждый пакет, включая транзитивные, с точной версией и криптографической контрольной суммой.
Без lock-файла установка недетерминирована: сегодня ^2.4.0 разрешится в 2.4.1, завтра мейнтейнер выпустит 2.5.0, и вы получите другой код при том же коммите. Это ровно та «недетерминированность с причиной», о которой шла речь в прошлой главе — только теперь она умеет проявляться через полгода после того, как вы написали код.
// package.json — манифест: диапазоны, написанные человеком
{
"name": "billing-api",
"dependencies": {
"express": "^4.19.2", // >=4.19.2 <5.0.0
"pg": "~8.12.0", // >=8.12.0 <8.13.0
"zod": "3.23.8" // ровно эта версия
}
}
// package-lock.json — то, что реально установлено (фрагмент).
// Обратите внимание на integrity: это подпись содержимого архива.
{
"packages": {
"node_modules/express": {
"version": "4.19.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.19.2.tgz",
"integrity": "sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==",
"dependencies": { "body-parser": "1.20.2", "cookie": "0.6.0" }
}
}
}
# pyproject.toml — манифест PEP 621
[project]
name = "billing-api"
requires-python = ">=3.12"
dependencies = [
"fastapi>=0.115,<0.116",
"sqlalchemy>=2.0,<3.0",
]
# uv.lock (фрагмент) — точные версии и хеши всех колёс,
# включая транзитивные зависимости, которых нет в манифесте
[[package]]
name = "fastapi"
version = "0.115.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [{ name = "pydantic" }, { name = "starlette" }]
wheels = [{ url = "https://files.pythonhosted.org/.../fastapi-0.115.6-py3-none-any.whl",
hash = "sha256:e9240b29e36fa8f4bb7290316988e90c381e5092e0cbe84e7818cc3713bcf305" }]
// go.mod — манифест. Версии здесь означают «не ниже», а не «ровно».
module github.com/acme/billing
go 1.23
require (
github.com/jackc/pgx/v5 v5.7.1
github.com/go-chi/chi/v5 v5.1.0
)
require github.com/jackc/puddle/v2 v2.2.2 // indirect — транзитивная
// go.sum — не «lock» в привычном смысле, а таблица контрольных сумм.
// Роль lock играет сам go.mod: алгоритм MVS по нему выбирает версии однозначно.
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
# Cargo.toml — манифест. "1.0" в Cargo означает ^1.0: SemVer-совместимое.
[dependencies]
serde = { version = "1.0", features = ["derive"] }
tokio = { version = "1.41", features = ["full"] }
# Cargo.lock — точный снимок графа. Коммитится для приложений;
# для библиотек исторически не коммитился, сейчас рекомендуют коммитить и там.
[[package]]
name = "serde"
version = "1.0.215"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6513c1ad0b11a9376da888e3e0baa0077f1aed55c17f50e7b2397136129fb88f"
Три разных подхода к одной задаче
| Экосистема | Как хранит | Как разрешает | Может ли жить две версии одного пакета |
|---|---|---|---|
| npm / yarn | вложенное дерево node_modules с подъёмом (hoisting) общих версий наверх |
максимальная версия, удовлетворяющая диапазону | да — конфликт «решается» дублированием |
| pnpm | глобальное контент-адресуемое хранилище + символические ссылки | то же, но без дублирования на диске | да, но каждая версия на диске одна |
| Python (uv, poetry, pip) | плоская site-packages |
SAT-подобный решатель по всем ограничениям сразу | нет — конфликт = ошибка установки |
| Go | модульный кэш, версии в пути импорта | Minimal Version Selection | да, но только разные мажорные (/v2 в пути) |
| Cargo | единый граф крейтов | объединение SemVer-совместимых требований | да, для несовместимых мажорных |
| Maven | плоский classpath | «ближайшее побеждает» (nearest-wins) по глубине дерева | нет — на classpath одна версия |
Разница глубже, чем кажется. npm выбирает максимальную допустимую версию — оптимистичная стратегия: вы получаете свежие исправления, но и свежие поломки. Go выбирает минимальную версию, удовлетворяющую всем требованиям — MVS Расса Кокса: если ваш модуль просит v1.2.0, а зависимость просит v1.4.0, будет взята v1.4.0, но никогда v1.7.0, вышедшая вчера. Сборка одного и того же коммита через год даёт тот же результат без всякого lock-файла — это свойство алгоритма, а не файла. Cargo строит единый граф и объединяет SemVer-совместимые требования: ^1.2 и ^1.5 схлопываются в одну версию 1.7, но ^1.0 и ^2.0 сосуществуют как два разных крейта в бинаре.
Разные версии одного пакета рядом — не всегда благо. В Rust и npm это спасает от тупика, но порождает класс ошибок «тип из версии 1 не подходит туда, где ждут тип из версии 2», а в JS ещё и раздувает бандл.
SemVer по-настоящему
Семантическое версионирование — это МАЖОР.МИНОР.ПАТЧ и обещание автора:
| Часть | Когда увеличивается | Что обещано потребителю |
|---|---|---|
ПАТЧ (1.4.2 → 1.4.3) |
исправление, не меняющее контракт | можно обновиться не глядя |
МИНОР (1.4.3 → 1.5.0) |
новая функциональность, обратно совместимая | старый код продолжит работать |
МАЖОР (1.5.0 → 2.0.0) |
ломающее изменение | читайте migration guide, готовьтесь править код |
Отсюда синтаксис диапазонов: ^1.2.3 означает «любая версия до 2.0.0», ~1.2.3 — «только патчи внутри 1.2.x». Отдельная ловушка — нулевой мажор: по спецификации 0.y.z не даёт никаких гарантий, и ^0.2.3 в npm трактуется как >=0.2.3 <0.3.0, то есть минор ведёт себя как мажор. Библиотека годами живущая в 0.x — это явное заявление автора «я ничего не обещаю».
Главное, что нужно понять про ^1.2.3: это доверие незнакомому человеку. Вы разрешаете чужому мейнтейнеру в любой момент подложить вам новый код в следующую сборку. Обещание совместимости держится не на технике, а на дисциплине автора — никакой инструмент не проверяет, что минорный релиз действительно не ломает контракт.
И нарушения неизбежны. Не потому что авторы небрежны, а по причине, сформулированной в законе Хайрама:
При достаточном числе пользователей API неважно, что вы обещали в контракте: на любое наблюдаемое поведение вашей системы кто-нибудь да полагается.
Исправление опечатки в тексте ошибки ломает того, кто парсил эту строку. Ускорение функции ломает того, у кого гонка пряталась за медленным кодом. Изменение порядка ключей в JSON ломает того, кто сравнивал ответ побайтово. Формально это патч, фактически — ломающее изменение для конкретного потребителя.
Практический вывод: обновление зависимостей всегда нужно прогонять через тесты, даже патчи. И симметрично, если библиотеку пишете вы, — см. Совместимость и эволюция контрактов.
Разрешение версий и конфликты
Прямых зависимостей у проекта десятки, транзитивных — сотни или тысячи. Транзитивная зависимость опасна тем, что вы её не выбирали: она пришла как чужое решение, но выполняется в вашем процессе с вашими правами.
Ромбовидная зависимость (diamond dependency) — базовый конфликт: два ваших пакета требуют один и тот же третий, но разных версий.
Что делают экосистемы:
- Дублирование (npm, Cargo, Go для разных мажорных). Ставятся обе версии, каждый потребитель видит свою. Работает, пока типы из разных версий не встречаются на границе: если
auth-sdkвозвращает объектRequestверсии 1, аmetricsждётRequestверсии 2 — падение в рантайме или ошибка компиляции с загадочным «expected Request, found Request». - Единая версия (Python, Maven, classpath JVM). Решатель ищет версию, удовлетворяющую всем ограничениям сразу. Не нашёл — честная ошибка на этапе установки. Это неприятно, но лучше, чем
NoSuchMethodErrorв проде через три недели. - Ручное разрешение:
resolutionsв yarn,overridesв npm,dependencyManagementв Maven,[patch]в Cargo. Инструмент последнего шанса: вы принудительно навязываете версию всему графу. Работает — и создаёт долг, о котором через год никто не помнит.
Dependency hell наступает, когда обновление одного пакета тянет за собой обновление половины графа, а что-то в этой половине несовместимо. Классический выход — не доводить: маленькие частые обновления вместо редких больших.
Vendoring — копирование исходников зависимостей внутрь своего репозитория (go mod vendor, каталог vendor/, git submodule). Плюсы: сборка без сети, полная воспроизводимость даже если пакет удалили из реестра, зависимость видна в code review. Минусы: репозиторий раздувается, обновление становится ручной операцией, merge-конфликты в чужом коде. Разумно там, где недоступность реестра неприемлема: закрытые контуры, регулируемые отрасли, критичная инфраструктура.
Стоимость зависимости
Библиотека выглядит бесплатной: одна строка в манифесте против недели своей работы. Реальная стоимость растянута во времени:
- Обновления. Каждая зависимость — источник pull request’ов навсегда.
- Уязвимости. CVE в транзитивной зависимости четвёртого уровня — ваша проблема и ваш срок на исправление.
- Лицензия. GPL-код в проприетарном продукте — юридический риск, а не техническая деталь. См. Лицензирование.
- Автобусный фактор. Пакет, который тянут миллионы, а поддерживает один человек в свободное время — норма, а не исключение.
- Стоимость выхода. Чем глубже библиотека проросла в код, тем дороже отказ. Изоляция за собственным интерфейсом стоит дёшево на входе и спасает потом — это ровно Порт и адаптер.
Урок left-pad
22 марта 2016 года разработчик Азер Кочулу, поссорившись с npm из-за спора о названии пакета kik, снял с публикации все свои 273 пакета. Среди них был left-pad — одиннадцать строк кода, дополняющих строку пробелами слева. От него транзитивно зависели Babel, React Native и тысячи проектов. Сборки по всему миру начали падать в течение минут. npm восстановила пакет примерно через два с половиной часа, приняв беспрецедентное решение вернуть чужой удалённый код.
Из этого следует не «npm плохой», а три конкретные вещи:
- Микрозависимости имеют нулевую пользу и ненулевой риск. Одиннадцать строк не стоят узла в графе поставки.
- Реестр — точка отказа. Отсюда прокси-реестры и зеркала внутри компании,
npm ciиз кэша, vendoring в критичных контурах. - Правила изменились. npm ужесточила политику: снять пакет с публикации можно в первые 72 часа, дальше — только через поддержку и только если от него никто не зависит. Crates.io и Go module proxy пошли дальше: удаление версии там невозможно в принципе.
Чек-лист: брать библиотеку или писать самому
- Сколько её кода я реально использую — весь или одну функцию из сорока?
- Сколько своего кода понадобится вместо неё? Меньше сотни строк — почти всегда пишем сами.
- Когда был последний релиз? Сколько открытых issue без ответа?
- Сколько людей имеют права на публикацию? Один — это риск и по надёжности, и по безопасности.
- Какая лицензия и совместима ли она с нашей моделью распространения?
- Сколько транзитивных зависимостей она приносит? (
npm ls --all,go mod graph,pipdeptree) - Есть ли история CVE и как быстро их закрывали?
- Смогу ли я её заменить за разумное время, если завтра она умрёт?
- Это инфраструктура (криптография, парсер TLS, драйвер БД) — или удобство? Инфраструктуру никогда не пишем сами.
Обновление зависимостей как процесс
Правило, которое экономит больше всего нервов: регулярное мелкое обновление дешевле редкого крупного, и разница нелинейная. Обновить 40 пакетов на минор раз в неделю — рутина на 20 минут. Обновить те же 40 пакетов через год, когда половина ушла на мажор, — двухнедельный проект с непредсказуемым концом.
Инструменты: Renovate и Dependabot. Оба следят за реестром и открывают pull request’ы. Разумная конфигурация:
// renovate.json — рабочая конфигурация для сервиса
{
"extends": ["config:recommended"],
"timezone": "Europe/Moscow",
"schedule": ["before 6am on monday"],
"prConcurrentLimit": 5,
"packageRules": [
{
// Патчи и миноры инструментов разработки сливаем автоматически,
// но только если весь CI зелёный
"matchDepTypes": ["devDependencies"],
"matchUpdateTypes": ["patch", "minor"],
"automerge": true
},
{
// Мажоры — всегда руками, с чтением changelog
"matchUpdateTypes": ["major"],
"automerge": false,
"labels": ["breaking-change"]
},
{
// Обновления безопасности — вне расписания, немедленно
"matchDatasources": ["npm"],
"vulnerabilityAlerts": { "schedule": ["at any time"] }
}
]
}
Ключевое условие автослияния — доверие к своему CI. Если тесты флаки или покрытие символическое, автомерж превращается в автоматическую доставку багов в main. Сначала качественные ворота, потом автоматизация обновлений.
Воспроизводимая сборка
Воспроизводимая (детерминированная) сборка — та, что при одинаковом входе даёт побайтово одинаковый выход. Это не академическая роскошь: если два запуска дают разные артефакты, вы не можете доказать, что в проде работает именно то, что лежит в репозитории, — а значит, компрометацию сборочного сервера нельзя обнаружить сравнением.
Источники недетерминизма, в порядке частоты:
| Источник | Пример | Лечение |
|---|---|---|
| Время | timestamp в архиве, __DATE__, «собрано 12.03» в футере |
SOURCE_DATE_EPOCH, нормализация метаданных |
| Порядок файлов | readdir возвращает файлы в порядке ФС |
явная сортировка перед упаковкой |
| Случайные идентификаторы | UUID в манифесте, случайные имена классов CSS | детерминированный хеш от содержимого |
| Неприпинованный вход | FROM node:20, apt-get install curl |
пин по digest, зафиксированный снапшот репозитория |
| Параллелизм | порядок объединения объектных файлов | детерминированный линкер, фиксированный порядок |
| Окружение | локаль, часовой пояс, абсолютные пути, $HOME |
сборка в контейнере, LC_ALL=C, -trimpath в Go |
:latest — главный враг воспроизводимости. Тег в реестре образов — движущаяся ссылка: сегодня python:3.12-slim указывает на один digest, через неделю на другой. Пин по digest превращает ссылку в неизменяемую:
# Плохо: тег может быть перевешен в любой момент
FROM python:3.12-slim
# Хорошо: digest — это хеш содержимого, он не может измениться
FROM python:3.12-slim@sha256:2b5b9e1e0f6a2d0e9c6d3f4a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c
Поверх воспроизводимости строятся две вещи, которые сегодня спрашивают на аудитах:
- SBOM (Software Bill of Materials) — машиночитаемая опись всего, что попало в артефакт. Форматы SPDX и CycloneDX, генераторы
syft,trivy, встроенные средства сборщиков. Когда завтра публикуют критическую уязвимость в библиотеке X, SBOM отвечает на вопрос «есть ли она у нас и где» за секунды, а не за неделю. - Provenance и SLSA — подписанное утверждение «этот артефакт собран вот этим пайплайном из вот этого коммита». Уровни SLSA описывают, насколько сложно это утверждение подделать.
Движение reproducible-builds.org выросло из Debian и показало, что побайтовая воспроизводимость достижима даже для дистрибутива целиком. Детали атак на цепочку поставки и защиты от них — в Supply chain.
Артефакты и реестры
Артефакт — единица того, что вы произвели: .jar, .whl, .tgz, статический бинарь, образ контейнера, .deb. Реестр — хранилище артефактов с версионированием и доступом: npm registry, PyPI, Maven Central, Docker Hub, GitHub Packages, Artifactory, Nexus.
Три правила, нарушение которых стоит дорого:
- Версия иммутабельна. Опубликованная
1.4.2не должна никогда измениться. Ошиблись — выпускайте1.4.3. Реестры, разрешающие перезапись, делают воспроизводимость невозможной; поэтому npm запрещает повторную публикацию версии, а crates.io не даёт удалять. - Промоушен вместо пересборки. Артефакт собирается один раз и продвигается по окружениям: dev → staging → prod. Меняется только конфигурация снаружи (Twelve-Factor), не содержимое. Пересборка «того же» для прода означает, что вы деплоите непроверенное.
- Retention нужен и он должен быть осознанным. Хранить все snapshot-сборки вечно — дорого; удалять по возрасту вслепую — однажды снести образ, который ещё крутится в проде. Разумно: релизные версии храним долго, ветки и pull request’ы — недели, digest’ы работающих в проде образов помечаем как неудаляемые.
Подробнее про реестры образов и теги — Контейнеры и реестры; про форматы упаковки под разные платформы — Упаковка и Обновления и версии.
Кэширование: почему сборка ускоряется в разы
Сборка на 90 % состоит из повторного выполнения того, что не менялось. Весь выигрыш — в том, чтобы это заметить.
Слои Docker и порядок инструкций
Docker кэширует каждый слой и переиспользует его, пока не изменились инструкция и её входные файлы. Изменился слой — все последующие пересобираются. Отсюда правило: то, что меняется редко, ставим раньше.
# ДО. Меняем одну строку в коде — заново ставятся все зависимости.
FROM node:22-slim
WORKDIR /app
COPY . . # исходники меняются каждый коммит...
RUN npm install # ...значит, этот слой не переиспользуется никогда
RUN npm run build
CMD ["node", "dist/server.js"]
# ПОСЛЕ. Порядок исправлен + multi-stage: в финальный образ
# не попадают ни исходники, ни devDependencies, ни компилятор.
# --- стадия сборки ---
FROM node:22-slim@sha256:... AS builder
WORKDIR /app
# Сначала только манифест и lock: слой меняется, лишь когда меняются зависимости
COPY package.json package-lock.json ./
# npm ci ставит строго по lock-файлу и падает при расхождении с манифестом
RUN --mount=type=cache,target=/root/.npm npm ci
# Теперь исходники: правка кода инвалидирует только слои ниже
COPY . .
RUN npm run build
RUN npm prune --omit=dev # выкидываем devDependencies
# --- финальный образ ---
FROM gcr.io/distroless/nodejs22-debian12@sha256:... AS runtime
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
USER nonroot
CMD ["dist/server.js"]
Что изменилось по существу: правка кода теперь пересобирает секунды вместо минут; финальный образ уменьшается в разы (нет npm, компилятора, исходников, dev-зависимостей); поверхность атаки сжимается до рантайма и приложения. --mount=type=cache из BuildKit сохраняет кэш пакетного менеджера между сборками, не помещая его в слой.
Кэш в CI и удалённый кэш
Ключ кэша — хеш lock-файла, а не имя ветки. Хеш меняется ровно тогда, когда меняется набор зависимостей; ветка меняется постоянно и даёт бесполезные промахи.
Дальше идут инструменты, которые кэшируют не только зависимости, но и результаты отдельных задач: Bazel, Nx, Turborepo, Gradle build cache. Идея одна: у каждой задачи вычисляется хеш от входов (исходники, конфиг, версии инструментов, результаты зависимых задач); если такой хеш уже собирался — результат берётся из кэша, локального или общего на всю команду. На большом репозитории это разница между 40 минутами и двумя.
Монорепо и полирепо со стороны сборки
Со стороны сборки главный вопрос — как устроен граф задач и что считается его входом.
В монорепо граф общий: система сборки видит все проекты сразу и умеет считать, какие задачи затронуты конкретным изменением (nx affected, turbo run build --filter=...[HEAD^], bazel query). Внутренние зависимости не публикуются в реестр — они просто узлы графа, а значит, изменение библиотеки и всех её потребителей едет одним атомарным коммитом. Цена: без инкрементальности и удалённого кэша сборка деградирует линейно от размера репозитория, а инструменты вроде Bazel требуют серьёзных вложений.
В полирепо граф разрезан по границам репозиториев, и склейкой служит реестр: библиотека публикует версию, потребитель обновляет её у себя. Сборка каждого репозитория проста и быстра, но сквозное изменение превращается в цепочку из нескольких pull request’ов, а «какая версия библиотеки где используется» становится отдельным вопросом.
Организационная и Git-сторона выбора — Монорепозиторий; модульные границы — Связность и связанность.
Типичные ошибки
latestгде угодно. ВFROM, в теге деплоя, в зависимости. Означает «пусть сборка зависит от того, какой сегодня день». Особая форма —latestв проде: невозможно понять, что именно работает, и невозможно откатиться.- Lock-файл в
.gitignore. Обычно попадает туда «потому что он большой и конфликтует». Убирает единственную гарантию воспроизводимости. Конфликты в lock-файле решаются перегенерацией (npm install,uv lock), а не игнорированием файла. npm installвместоnpm ciв CI.installможет обновить lock-файл прямо в конвейере, и вы соберёте не то, что лежит в репозитории.ciставит строго по lock и падает, если lock разошёлся с манифестом, — именно то поведение, которое нужно в автоматике. Аналоги:uv sync --frozen,poetry install --no-update,go mod verify,cargo build --locked.- Сборка внутри прод-образа. Компилятор, заголовки, тулчейн и исходники в проде — это лишние сотни мегабайт и большая поверхность атаки. Лечится multi-stage.
- Обновление всего разом перед релизом. Худшее время для роста энтропии. Обновления — отдельным потоком, вне релизного окна.
- Отключённая проверка целостности.
--no-verify,GONOSUMDB=*, доверенный зеркальный реестр без проверки подписей. Ровно та дверь, через которую заходят атаки на цепочку поставки. - Закоммиченный
node_modules. Не то же самое, что осознанный vendoring: раздувает репозиторий, ломает бинарные зависимости между ОС и всё равно не даёт воспроизводимости без lock-файла. - Один общий пакет «utils», от которого зависят все. Каждое изменение пересобирает весь граф и обнуляет кэш. Разрежьте по смыслу.
Мини-итог
- Стадии сборки одинаковы во всех языках: разрешение зависимостей → преобразование кода → склейка → упаковка → публикация. В сеть ходит только первая.
- Манифест — что я хочу, lock-файл — что я получил. Lock коммитится всегда; без него сборка не воспроизводима.
- Экосистемы разрешают версии по-разному: npm берёт максимум допустимого, Go — минимум по MVS, Cargo объединяет SemVer-совместимое, Python и Maven требуют единственную версию.
^1.2.3— это доверие незнакомому человеку. Закон Хайрама гарантирует, что нарушения SemVer будут; поэтому любое обновление проходит через тесты.- Ромбовидный конфликт решается дублированием (npm, Cargo) или ошибкой установки (Python, Maven). Второе честнее.
- Зависимость стоит не строчку в манифесте, а обновления, CVE, лицензию и стоимость выхода. Меньше сотни строк — пишем сами; криптографию — никогда.
- Воспроизводимость ломают время, порядок файлов, случайные ID и
latest. Пин по digest,SOURCE_DATE_EPOCH, SBOM и provenance — базовый набор. - Артефакт собирается один раз и продвигается по окружениям. Версия в реестре иммутабельна.
- Кэш даёт кратный выигрыш: правильный порядок слоёв Docker, ключ кэша по хешу lock-файла, remote cache для больших репозиториев.
Что дальше
Качество в потоке — линтеры, статический анализ, тесты и ревью как автоматические ворота, через которые проходит каждое изменение, прежде чем стать артефактом.