fix(ci): деплой падает громко, если :latest отстаёт от головы по компоненту (#2950)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m7s
CI / backend-tests (pull_request) Successful in 17m41s

Почему: деплой катит IMAGE_TAG=latest. Forgejo отменяет ещё не стартовавший
deploy предыдущего run'а той же группы при новом push (cancel-in-progress:
false на pending-job не действует): 21.08 10:35:13 deploy run'а ccf84b4a
«Has been cancelled» через 4 с после старта run'а 95db3f44 (ops-only, билды
пропущены), и тот накатил :latest как есть. Прод получил новый код только
потому, что билды ccf84b4a легли в 10:34:08/14 — за 70 с до pull'а; иначе
голова была бы зелёной, а прод — на старом образе. Host-lock (#2955)
закрывает гонку прунов, но не эту.

Что: build-push-action ставит org.opencontainers.image.revision=github.sha
(12 шагов: попытка+ретрай × 6 образов). В deploy-job обоих workflow перед
SSH — scripts/check-latest-image-revision.sh: читает ревизию :latest из
registry (imagetools inspect), требует, чтобы она содержала последний
коммит по путям компонента (те же фильтры, что запускают сборку), ждёт билд
предшественника до 15 мин, иначе ::error и выход 1. Ревизия новее — ок
(workflow_dispatch собирает голову).

Тест test_2950_latest_image_revision_gate.py: подменный docker на PATH +
настоящий временный git; коды выхода по значению (свежий 0, новее 0, отстаёт
1 с ожиданием, чужой компонент 1, без метки 1, registry упал 1, usage 2);
мутация «всегда 0» → 5/7 красных.

Refs #2950
This commit is contained in:
bot-backend 2026-08-21 16:50:34 +05:00
parent 1782dae0e2
commit 54889a956b
4 changed files with 275 additions and 0 deletions

View file

@ -297,6 +297,8 @@ jobs:
context: ./tradein-mvp
file: ./tradein-mvp/backend/Dockerfile
push: true
labels: |
org.opencontainers.image.revision=${{ github.sha }}
# APP_VERSION/BUILD_SHA/BUILD_DATE → runtime env в образе (см.
# backend/Dockerfile ARG→ENV) — читает app/core/version.py:
# GET /api/v1/trade-in/version + колонтитул PDF-отчёта.
@ -321,6 +323,8 @@ jobs:
context: ./tradein-mvp
file: ./tradein-mvp/backend/Dockerfile
push: true
labels: |
org.opencontainers.image.revision=${{ github.sha }}
build-args: |
APP_VERSION=${{ needs.changes.outputs.app_version }}
BUILD_SHA=${{ needs.changes.outputs.build_sha }}
@ -425,6 +429,8 @@ jobs:
with:
context: ./tradein-mvp/frontend
push: true
labels: |
org.opencontainers.image.revision=${{ github.sha }}
# basePath=/trade-in baked-in во время build (Next.js)
# NB (#2205): НЕ передаём NEXT_PUBLIC_ENABLE_PREVIEW — preview-роут
# (/ui-preview/estimate, статичная demo-фикстура) собирается ТОЛЬКО в
@ -454,6 +460,8 @@ jobs:
with:
context: ./tradein-mvp/frontend
push: true
labels: |
org.opencontainers.image.revision=${{ github.sha }}
build-args: |
NEXT_PUBLIC_BASE_PATH=/trade-in
NEXT_PUBLIC_API_BASE_URL=/trade-in
@ -549,6 +557,8 @@ jobs:
with:
context: ./tradein-mvp/browser
push: true
labels: |
org.opencontainers.image.revision=${{ github.sha }}
cache-from: type=registry,ref=${{ env.IMAGE_BROWSER }}:buildcache
cache-to: type=registry,ref=${{ env.IMAGE_BROWSER }}:buildcache,mode=max
tags: |
@ -564,6 +574,8 @@ jobs:
with:
context: ./tradein-mvp/browser
push: true
labels: |
org.opencontainers.image.revision=${{ github.sha }}
cache-to: type=registry,ref=${{ env.IMAGE_BROWSER }}:buildcache,mode=max
tags: |
${{ env.IMAGE_BROWSER }}:latest
@ -603,6 +615,23 @@ jobs:
needs.build-frontend.result != 'failure' &&
needs.build-browser.result != 'failure'
steps:
# ── #2950: :latest не старше последнего коммита по компоненту ─────────────
# См. комментарий к тому же шагу в deploy.yml и scripts/check-latest-image-revision.sh.
# Пути = фильтры job'а changes (backend/frontend/browser + infra), которые
# приводят к сборке соответствующего образа.
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Login to GHCR — для imagetools inspect гарда (#2950)
env:
GHCR_PAT: ${{ secrets.GHCR_PAT }}
run: echo "$GHCR_PAT" | docker login ghcr.io -u lekss361 --password-stdin
- name: Гард свежести :latest (#2950)
run: |
INFRA="tradein-mvp/docker-compose.prod.yml tradein-mvp/deploy .forgejo/workflows/deploy-tradein.yml"
scripts/check-latest-image-revision.sh "$IMAGE_BACKEND" 900 -- tradein-mvp/backend tradein-mvp/packages/scraper-kit tradein-mvp/VERSION $INFRA
scripts/check-latest-image-revision.sh "$IMAGE_FRONTEND" 900 -- tradein-mvp/frontend tradein-mvp/VERSION tradein-mvp/CHANGELOG.md $INFRA
scripts/check-latest-image-revision.sh "$IMAGE_BROWSER" 900 -- tradein-mvp/browser $INFRA
- name: Deploy via SSH
uses: appleboy/ssh-action@v1.0.3
env:

View file

@ -173,6 +173,8 @@ jobs:
context: ./backend
target: runner
push: true
labels: |
org.opencontainers.image.revision=${{ github.sha }}
cache-from: type=registry,ref=${{ env.IMAGE_BACKEND }}:buildcache
cache-to: type=registry,ref=${{ env.IMAGE_BACKEND }}:buildcache,mode=max
tags: |
@ -194,6 +196,8 @@ jobs:
context: ./backend
target: runner
push: true
labels: |
org.opencontainers.image.revision=${{ github.sha }}
cache-to: type=registry,ref=${{ env.IMAGE_BACKEND }}:buildcache,mode=max
tags: |
${{ env.IMAGE_BACKEND }}:latest
@ -291,6 +295,8 @@ jobs:
context: ./backend
target: runner-with-chromium
push: true
labels: |
org.opencontainers.image.revision=${{ github.sha }}
cache-from: type=registry,ref=${{ env.IMAGE_WORKER }}:buildcache
cache-to: type=registry,ref=${{ env.IMAGE_WORKER }}:buildcache,mode=max
tags: |
@ -308,6 +314,8 @@ jobs:
context: ./backend
target: runner-with-chromium
push: true
labels: |
org.opencontainers.image.revision=${{ github.sha }}
cache-to: type=registry,ref=${{ env.IMAGE_WORKER }}:buildcache,mode=max
tags: |
${{ env.IMAGE_WORKER }}:latest
@ -400,6 +408,8 @@ jobs:
with:
context: ./frontend
push: true
labels: |
org.opencontainers.image.revision=${{ github.sha }}
build-args: |
NEXT_PUBLIC_GLITCHTIP_DSN=${{ secrets.GLITCHTIP_FRONTEND_DSN }}
NEXT_PUBLIC_ENVIRONMENT=production
@ -419,6 +429,8 @@ jobs:
with:
context: ./frontend
push: true
labels: |
org.opencontainers.image.revision=${{ github.sha }}
build-args: |
NEXT_PUBLIC_GLITCHTIP_DSN=${{ secrets.GLITCHTIP_FRONTEND_DSN }}
NEXT_PUBLIC_ENVIRONMENT=production
@ -461,6 +473,28 @@ jobs:
needs.build-worker.result != 'failure' &&
needs.build-frontend.result != 'failure'
steps:
# ── #2950: :latest не старше последнего коммита по компоненту ─────────────
# Forgejo отменяет ещё не стартовавший deploy предыдущего run'а этой группы,
# а следующий run (например ops-only, билды пропущены) катит :latest как есть.
# 21.08.2026 10:35 прод получил новый код только потому, что билды
# предшественника успели за 70 с до pull'а. Гард читает метку ревизии из
# образа в registry (labels на build-push выше), ждёт билд предшественника
# до 15 мин и иначе падает громко — вместо тихого отката при зелёной голове.
# Пути = фильтры job'а changes, которые приводят к сборке (caddy_only не
# собирает — Caddyfile/caddy/** намеренно не в списке).
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Login to GHCR — для imagetools inspect гарда (#2950)
env:
GHCR_PAT: ${{ secrets.GHCR_PAT }}
run: echo "$GHCR_PAT" | docker login ghcr.io -u lekss361 --password-stdin
- name: Гард свежести :latest (#2950)
run: |
INFRA="docker-compose.prod.yml .forgejo/workflows/deploy.yml"
scripts/check-latest-image-revision.sh "$IMAGE_BACKEND" 900 -- backend data/sql $INFRA
scripts/check-latest-image-revision.sh "$IMAGE_WORKER" 900 -- backend data/sql $INFRA
scripts/check-latest-image-revision.sh "$IMAGE_FRONTEND" 900 -- frontend $INFRA
- name: Deploy to VM via SSH
uses: appleboy/ssh-action@v1.0.3
env:

View file

@ -0,0 +1,142 @@
"""Гард свежести :latest перед деплоем (#2950) — коды выхода по значению.
Сценарий, ради которого гард существует: Forgejo отменил ещё не стартовавший deploy
run'а A, run B (билды пропущены) катит :latest, а :latest несёт ревизию СТАРШЕ
последнего коммита по компоненту билд A не доехал. Гард обязан ждать и, не
дождавшись, падать; а при ревизии не старше последнего коммита пропускать.
`docker` подменяется шимом на PATH: он печатает конфиг образа с меткой ревизии из
переменной окружения FAKE_REV (пусто метки нет; FAKE_DOCKER_FAIL=1 registry не
ответил). Временный git-репозиторий даёт настоящие коммиты для `git log`/merge-base.
"""
from __future__ import annotations
import os
import stat
import subprocess
from pathlib import Path
import pytest
КОРЕНЬ = Path(__file__).resolve().parents[2]
СКРИПТ = КОРЕНЬ / "scripts" / "check-latest-image-revision.sh"
_SHIM = """#!/usr/bin/env bash
# подменный docker: только `buildx imagetools inspect <img> --format ...`
if [ "${FAKE_DOCKER_FAIL:-}" = "1" ]; then echo "unauthorized" >&2; exit 1; fi
FMT='{"linux/amd64": {"config": {"Labels": {"org.opencontainers.image.revision": "%s"}}}}'
if [ -n "${FAKE_REV:-}" ]; then
printf "$FMT" "$FAKE_REV"
else
printf '{"linux/amd64": {"config": {"Labels": {}}}}'
fi
"""
def _git(repo: Path, *args: str) -> str:
return subprocess.run(
["git", *args],
cwd=repo,
check=True,
capture_output=True,
text=True,
env={
**os.environ,
"GIT_AUTHOR_NAME": "t",
"GIT_AUTHOR_EMAIL": "t@t",
"GIT_COMMITTER_NAME": "t",
"GIT_COMMITTER_EMAIL": "t@t",
},
).stdout.strip()
def _commit(repo: Path, rel: str, msg: str) -> str:
p = repo / rel
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(msg, encoding="utf-8")
_git(repo, "add", rel)
_git(repo, "commit", "-q", "-m", msg)
return _git(repo, "rev-parse", "HEAD")
@pytest.fixture()
def repo(tmp_path: Path) -> dict[str, object]:
r = tmp_path / "repo"
r.mkdir()
_git(r, "init", "-q")
c1 = _commit(r, "backend/a.py", "c1 backend")
c2 = _commit(r, "frontend/b.ts", "c2 frontend")
c3 = _commit(r, "backend/c.py", "c3 backend")
c4 = _commit(r, "frontend/d.ts", "c4 frontend")
shim_dir = tmp_path / "bin"
shim_dir.mkdir()
shim = shim_dir / "docker"
shim.write_text(_SHIM, encoding="utf-8")
shim.chmod(shim.stat().st_mode | stat.S_IEXEC)
return {"path": r, "c1": c1, "c2": c2, "c3": c3, "c4": c4, "bin": shim_dir}
def _run(
repo: dict[str, object], rev: str | None, *paths: str, fail: bool = False, timeout: str = "1"
) -> subprocess.CompletedProcess[str]:
env = {
**os.environ,
"PATH": f"{repo['bin']}{os.pathsep}{os.environ['PATH']}",
"CHECK_LATEST_SLEEP_S": "0.2",
"FAKE_REV": rev or "",
"FAKE_DOCKER_FAIL": "1" if fail else "",
}
return subprocess.run(
[str(СКРИПТ), "ghcr.io/x/img", timeout, "--", *paths],
cwd=repo["path"],
env=env,
capture_output=True,
text=True,
)
def test_fresh_latest_passes(repo: dict[str, object]) -> None:
""":latest собран из последнего коммита по backend (c3) → 0."""
r = _run(repo, str(repo["c3"]), "backend")
assert r.returncode == 0, r.stdout + r.stderr
assert "содержит последний коммит" in r.stdout
def test_newer_revision_passes(repo: dict[str, object]) -> None:
"""Ревизия новее последнего коммита по компоненту (dispatch собрал голову c4) → 0."""
r = _run(repo, str(repo["c4"]), "backend")
assert r.returncode == 0, r.stdout + r.stderr
def test_stale_latest_fails_after_timeout(repo: dict[str, object]) -> None:
"""Головной: :latest из c1, а по backend последний — c3 → ждёт, затем 1 с ::error."""
r = _run(repo, str(repo["c1"]), "backend")
assert r.returncode == 1, r.stdout + r.stderr
assert "::error::" in r.stderr and "отстаёт" in r.stderr
assert "жду билд предшественника" in r.stdout, "гард обязан ЖДАТЬ, а не падать сразу"
def test_revision_from_other_component_does_not_cover(repo: dict[str, object]) -> None:
"""Контроль путей: для frontend последний — c4; :latest из c3 (до c4) → 1."""
r = _run(repo, str(repo["c3"]), "frontend")
assert r.returncode == 1, r.stdout + r.stderr
def test_missing_label_fails(repo: dict[str, object]) -> None:
"""Без метки ревизии нельзя утверждать свежесть → 1, а не молчаливый пропуск."""
r = _run(repo, None, "backend")
assert r.returncode == 1, r.stdout + r.stderr
assert "метки нет" in r.stderr
def test_registry_error_fails(repo: dict[str, object]) -> None:
"""Registry не отвечает → 1 (не «проверить нельзя — значит можно»)."""
r = _run(repo, str(repo["c3"]), "backend", fail=True)
assert r.returncode == 1, r.stdout + r.stderr
def test_usage_errors_are_distinct(repo: dict[str, object]) -> None:
"""Ошибка вызова — код 2, чтобы её не приняли за «отстаёт»."""
r = subprocess.run([str(СКРИПТ), "img"], cwd=repo["path"], capture_output=True, text=True)
assert r.returncode == 2

View file

@ -0,0 +1,70 @@
#!/usr/bin/env bash
# Гард свежести образа :latest перед деплоем (#2950).
#
# ЗАЧЕМ. Деплой катит IMAGE_TAG=latest. Forgejo отменяет ещё не стартовавший
# `deploy` предыдущего run'а той же concurrency-группы, когда приходит новый push
# (cancel-in-progress: false объявлен, но на pending-job не действует — 21.08.2026
# 10:35:13 «Has been cancelled» через 4 с после старта следующего run'а). Следующий
# run — например ops-only, с пропущенными билдами — катит :latest как есть. 21.08
# прод получил новый код только потому, что билды предшественника успели за 70 с
# до pull'а; иначе голова main была бы зелёной, а прод — на старом образе.
#
# ЧТО ПРОВЕРЯЕТ. Образ :latest в registry несёт метку org.opencontainers.image.revision
# (ставится build-push-action в deploy.yml / deploy-tradein.yml). Гард требует, чтобы
# эта ревизия СОДЕРЖАЛА последний коммит, трогавший пути компонента (git log по
# checkout'у run'а): то есть :latest не старше того, что обязано было собраться.
# Ревизия новее — нормально (workflow_dispatch собирает всё на голове).
# Пока условие не выполнено — ждёт (билд предшественника ещё пушит) до таймаута,
# затем падает громко: лучше красный деплой, чем тихий откат.
#
# Использование: check-latest-image-revision.sh <image-repo> <timeout_s> -- <path>...
# CHECK_LATEST_SLEEP_S — пауза между опросами (по умолчанию 20; тесты ставят меньше).
# Коды: 0 — свежий; 1 — отстаёт/метки нет/registry не ответил до таймаута; 2 — usage.
set -euo pipefail
IMAGE="${1:-}"; TIMEOUT="${2:-}"
if [ -z "$IMAGE" ] || [ -z "$TIMEOUT" ] || [ "${3:-}" != "--" ] || [ $# -lt 4 ]; then
echo "usage: $0 <image-repo> <timeout_s> -- <path>..." >&2; exit 2
fi
shift 3
SLEEP="${CHECK_LATEST_SLEEP_S:-20}"
LAST="$(git log -1 --format=%H -- "$@" || true)"
if [ -z "$LAST" ]; then
echo "::error::$IMAGE — нет ни одного коммита по путям: $*" >&2; exit 2
fi
read_revision() {
# Печатает ревизию из метки образа или пустую строку. .Image для multi-arch —
# карта по платформам, для single-arch — сам конфиг; разбираем оба вида.
docker buildx imagetools inspect "$IMAGE:latest" --format '{{json .Image}}' 2>/dev/null \
| python3 -c '
import json, sys
try:
d = json.load(sys.stdin)
except Exception:
sys.exit(0)
cands = [d] if isinstance(d, dict) and "config" in d else [v for v in d.values() if isinstance(v, dict)]
for c in cands:
rev = ((c.get("config") or {}).get("Labels") or {}).get("org.opencontainers.image.revision")
if rev:
print(rev); break
' || true
}
deadline=$(( $(date +%s) + TIMEOUT ))
while :; do
REV="$(read_revision)"
if [ -n "$REV" ] && git cat-file -e "${REV}^{commit}" 2>/dev/null \
&& git merge-base --is-ancestor "$LAST" "$REV"; then
echo "$IMAGE:latest — ревизия ${REV:0:10} содержит последний коммит по компоненту ${LAST:0:10}"
exit 0
fi
now=$(date +%s)
if [ "$now" -ge "$deadline" ]; then
echo "::error::$IMAGE:latest несёт ревизию '${REV:-<метки нет / registry не ответил>}', а последний коммит, трогавший [$*] — ${LAST:0:10}. :latest отстаёт (билд предшественника не доехал или отменён) — деплой остановлен, иначе прод получил бы старый образ при зелёной голове (#2950). Если билд того коммита упал — чини его или запусти workflow_dispatch полного деплоя." >&2
exit 1
fi
echo "$IMAGE:latest — ревизия '${REV:-<метки нет>}' ещё не содержит ${LAST:0:10}; жду билд предшественника ($(( deadline - now )) с)"
sleep "$SLEEP"
done