Перейти к основному содержимому

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 дало бы одной политике два адреса.