ADR 0159 — Сторонний образ входит по digest, и lint проваливает того, кто забудет
Статус
Accepted. ПЕРЕСМАТРИВАЕТ решение, объявленное письменно в
docs/explanation/cadeia-de-suprimentos-do-ci.md, — «закрепление по тегу —
намеренная остановка, а не небрежность», — и отменяет его с измерением. Не
трогает ADR 0119, который
остаётся владельцем четырёх образов, публикуемых продуктом. Соответствует
BRB-004, P2 реестра сопровождающего.
Контекст
ADR 0119 заставил четыре
СОБСТВЕННЫХ образа идти по digest, а #419 дал репозиторию механизм для другой
стороны того же семейства: scripts/ci/actions-pinadas.ts, который проваливает
uses:, закреплённый тегом. Его docblock формулирует довод:
Tag é um ponteiro que o dono da action pode remover e recriar apontando para outro commit (…). Quem move a tag executa código no runner que tem o checkout e, nos workflows de release, as credenciais do GHCR e do npm.
Этот довод никогда не применялся к image:, и страница цепочки поставок
говорила почему:
Third-party images are pinned by tag, not by digest (…) so this is weaker than the action pins — a deliberate stop, not an oversight. It buys the reproducibility that matters day to day (…) without the maintenance cost of digests on images we don't publish.
У фразы две посылки, и измерение опрокидывает обе.
«Образы, которые мы не публикуем». Измерено 2026-09-12: это 37 сторонних ссылок, и три из мест, где они работают, — не «повседневность».
- Тринадцать — это
FROMв Dockerfile —node:24.11.1-alpine3.21,hexpm/elixir:1.17.3-…,nginx:1.27.5-alpine,alpine:3.20/3.20.3,node:24-alpine. Это основа образов, которые мы публикуем: сторонние байты, которые мы подписываемcosign(RN-524) и отдаём другим людям.alpine:3.20изdocker/backup/Dockerfile.prod, к слову, — единственныйFROM, который уже называл самBRB-004. - Четыре — в
docker/docker-compose.install.yml, том, что поднимается на машине того, кто установил продукт, рядом с Postgres этого человека (RN-527). В том же файле четыре собственных образа приходят через переменную, уже разрешённую по digest, а сторонние приходили по тегу. - Четыре — в
.github/workflows/ci.ymlиgolden-set-rag.yml, какservices:job. Это буквально runner, который правило actions существует защищать, достигаемый через другую дверь, — и ни один из двух файлов в таком свете не рассматривался.BRB-004их тоже не перечислял.
«Цена поддержки». Реальна и не исчезает, но это ТА ЖЕ цена, которую репозиторий уже принял для actions, чей pin тоже двигается вручную. Покупает она то же самое: ссылаться на содержимое, а не на имя, которым управляет кто-то другой.
Есть ещё ловушка, зафиксированная письменно в docs/reference/brb.md, и она
объясняет, почему пункт так долго оставался открытым: прочитанный по id,
BRB-004 выглядит как закрепление ollama:latest, которое закрыли PR
#401/#419. "An id is not a description" — те PR заменили latest на
версию; этот требует digest.
Решение
1. Каждый сторонний образ закреплён по digest, с тегом в комментарии рядом — та же форма, что используют actions:
image: neo4j@sha256:22ec5cd0… # 5.26-community
В Dockerfile комментарий идёт на строке ВЫШЕ:
# 3.20
FROM alpine@sha256:d9e853e8… AS runtime
Digest — это digest индекса (manifest list), никогда не одной платформы:
закрепление манифеста linux/amd64 сломало бы linux-arm64 без
предупреждения. Комментарий обязателен, по причине, идентичной actions, —
sha256:22ec5cd0… никому не говорит, что это Neo4j 5.26, — и это один
токен, без пробела: именно это отделяет тег от ПРОЗЫ, которая уже живёт над
почти каждым FROM этого репозитория.
Позиция в Dockerfile — не дело вкуса, и она выучена на ошибке. Первая
версия этого изменения написала FROM alpine@sha256:… # 3.20, по симметрии с
compose. Парсер Docker распознаёт # только в НАЧАЛЕ строки: это не
закомментированный FROM, а FROM с тремя аргументами, и сборка умирает на
FROM requires either one or three arguments. У находки есть вторая половина,
стоящая больше первой, — hadolint прошёл на пяти Dockerfile. У него
собственный парсер, а согласие linter — не согласие сборки; провалил bake
job images, за 27 секунд.
Проверка унаследовала урок в ДВУХ формах. Она распознаёт комментарий в конце
FROM только чтобы его провалить — шаблон, который просто не совпадал бы с
неправильной строкой, заставил бы ссылку ИСЧЕЗНУТЬ из сканирования, вместо того
чтобы стать красной, обменяв ошибку на молчание, а это ровно тот дефект,
которого эта проверка существует, чтобы не иметь. И по той же причине
комментарий YAML захватывается целиком и оценивается потом, а не проверяется
внутри шаблона.
2. ПАРАЛЛЕЛЬНАЯ проверка, а не расширение. scripts/ci/imagens-pinadas.ts
выполняется в job lint, рядом с actions-pinadas.ts, и проваливает три
вещи: изменяемую ссылку, digest без тега в комментарии и один тег с двумя
разными digest в разных файлах. Это два скрипта, а не один, потому что это
два вопроса: uses: живёт в YAML workflow с одним синтаксисом; образ живёт в
compose, в манифесте kustomize и в Dockerfile, ещё с тремя. Функция, отвечающая
на оба, плохо отвечает на каждый, — то же рассуждение, которое
docs/.docmap.yml уже использует, чтобы разделить politica-de-branches и
checks-e-rulesets.
Третья причина существует ради конкретного обещания: golden-set-rag.yml
говорит в комментарии, что запускает «ТУ ЖЕ закреплённую версию из
docker/docker-compose.yml», потому что порог golden-set привязан к модели, а
не к окружению (ADR 0138). С тегом
это было обещание; с digest это становится проверяемым.
3. Проверка НЕ покрывает образы, которые публикует продукт, и решение намеренное, по трём независимым причинам:
- Они не сторонние. Не существует владельца вне этого репозитория, который мог бы сдвинуть какой-либо указатель.
- Это невозможно.
brabo-api:prod— ЛОКАЛЬНЫЙ тег, производимыйdocker compose build: digest не существует до сборки и меняется с каждой сборкой. Литерал там означал бы требовать несуществующее. - Это спорило бы с механизмом, который их уже решает. Там, где они
пересекают registry, ADR 0119
уже ставит их по digest —
.release/images.json, записанныйrelease.yml, применяемыйmake imagens-do-release. Overlay хранит МАРКЕР (REPLACE_WITH_DIGEST), а не замороженный релиз, намеренно: репозиторий не объявляет, что продакшен работает на v3.2.0. Проверка, требующая литерального digest в overlay, провалила бы именно правильное состояние.
По тем же причинам вне остаются интерполированные ссылки
(${BRABO_API_IMAGE:?…}) — install.sh записывает все четыре в .env уже по
digest — и стадии multi-stage (FROM deps AS build, FROM scratch),
которые не являются образами registry.
Список исключений — по имени, и он отказывает закрыто: новый сторонний
образ никогда не совпадёт с brabo-, поэтому рождается проверяемым.
4. Compose наблюдаемости входит, и это был открытый вопрос в задаче
(TODO(humano): входит в рамки проверки или остаётся вне, раз не поднимается
в пользовательской установке?). Ответ — цена каждой стороны. Включение стоит
четырёх digest. Исключение стоит названного исключения внутри docker/, а
это ровно тот формат дыры, который диагностирует docblock actions-pinadas.ts:
"workflow novo nasce copiando o vizinho, e o vizinho estava com tag".
Освобождённый файл посреди папки — неправильный сосед.
Последствия
Digest замораживает, и цена объявлена. Образ, закреплённый по digest, не
получает исправлений безопасности, пока кто-нибудь не сменит digest вручную, —
тот же долг, что несут SHA actions (BRB-006). Этот ADR не решает это:
объявляет. .github/dependabot.yml включает экосистему github-actions по
этой причине; экосистема docker здесь не включается, и её включение —
отдельное решение, потому что оно меняет каденцию PR монорепозитория, который
уже выключил version updates npm из-за наводнения.
Образы dev замораживаются сильнее, чем продакшена. node:24-alpine был
намеренно скользящим тегом, а digest привязывает его к конкретному 24.x. Это
самое некомфортное последствие этого решения, и оно принято по тому же доводу:
dev-образ broker выполняет pnpm install при сборке, а основа, меняющаяся
снизу, — лишняя переменная, когда что-то ломается только на чьей-то машине.
Изменились 37 строк, и ни одна не изменила содержимого. Каждый digest был разрешён против registry из тега, который уже был записан, и это digest индекса — сборки остаются multi-arch. Репозиторий потерял способность получать другое содержимое так, чтобы никто этого не увидел.
BRB-004 перестаёт воспроизводиться, и его формальное закрытие
по-прежнему за тем, кто его поднял, в vault, — мерка, которую уже формулирует
docs/reference/brb.md.
Чего этот ADR НЕ делает:
- Не распространяется на подпись. Digest — это целостность взятого;
происхождение — это
BRB-005, чья открытая половина (прокси проверяет подпись манифеста) была измерена и отвергнута с числами (RN-525). - Не покрывает
deploy/k8s/helm/otel-collector-values.yaml, гдеimage:открывает отображение сrepository:и без тега: тег там выбирает chart, и закрепление потребовало бы решить и версию chart. Остаётся ОБЪЯВЛЕННЫМ, не покрытым. - Не создаёт RN. Правило — сестра того, что запрещает
uses:по тегу, а у того тоже нет RN: оба живут вCLAUDE.md(раздел CI/CD релиза) и вdocs/explanation/cadeia-de-suprimentos-do-ci.md, за которымdocs/.docmap.ymlследит вblock. Поместить одно из двух вdocs/business-rules.mdдало бы одной политике два адреса.