From 54889a956b1472ae26dfbdafebbed83e70720c9e Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 21 Aug 2026 16:50:34 +0500 Subject: [PATCH 1/2] =?UTF-8?q?fix(ci):=20=D0=B4=D0=B5=D0=BF=D0=BB=D0=BE?= =?UTF-8?q?=D0=B9=20=D0=BF=D0=B0=D0=B4=D0=B0=D0=B5=D1=82=20=D0=B3=D1=80?= =?UTF-8?q?=D0=BE=D0=BC=D0=BA=D0=BE,=20=D0=B5=D1=81=D0=BB=D0=B8=20:latest?= =?UTF-8?q?=20=D0=BE=D1=82=D1=81=D1=82=D0=B0=D1=91=D1=82=20=D0=BE=D1=82=20?= =?UTF-8?q?=D0=B3=D0=BE=D0=BB=D0=BE=D0=B2=D1=8B=20=D0=BF=D0=BE=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=BC=D0=BF=D0=BE=D0=BD=D0=B5=D0=BD=D1=82=D1=83=20(#2950?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Почему: деплой катит 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 --- .forgejo/workflows/deploy-tradein.yml | 29 ++++ .forgejo/workflows/deploy.yml | 34 +++++ .../test_2950_latest_image_revision_gate.py | 142 ++++++++++++++++++ scripts/check-latest-image-revision.sh | 70 +++++++++ 4 files changed, 275 insertions(+) create mode 100644 backend/tests/test_2950_latest_image_revision_gate.py create mode 100755 scripts/check-latest-image-revision.sh diff --git a/.forgejo/workflows/deploy-tradein.yml b/.forgejo/workflows/deploy-tradein.yml index f2a2e7d3..1d8149de 100644 --- a/.forgejo/workflows/deploy-tradein.yml +++ b/.forgejo/workflows/deploy-tradein.yml @@ -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: diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml index fd04b561..95df986a 100644 --- a/.forgejo/workflows/deploy.yml +++ b/.forgejo/workflows/deploy.yml @@ -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: diff --git a/backend/tests/test_2950_latest_image_revision_gate.py b/backend/tests/test_2950_latest_image_revision_gate.py new file mode 100644 index 00000000..af602ef7 --- /dev/null +++ b/backend/tests/test_2950_latest_image_revision_gate.py @@ -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 --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 diff --git a/scripts/check-latest-image-revision.sh b/scripts/check-latest-image-revision.sh new file mode 100755 index 00000000..d69b2084 --- /dev/null +++ b/scripts/check-latest-image-revision.sh @@ -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 -- ... +# 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 -- ..." >&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 -- 2.45.3 From 4d9d03cb071a8151770a07337a32f77121b588a5 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 21 Aug 2026 16:52:07 +0500 Subject: [PATCH 2/2] =?UTF-8?q?test(ci):=20=D0=B3=D0=B0=D1=80=D0=B4=20#295?= =?UTF-8?q?0=20=E2=80=94=20=D0=B4=D0=B5=D0=B4=D0=BB=D0=B0=D0=B9=D0=BD=20?= =?UTF-8?q?=D1=82=D0=B5=D1=81=D1=82=D0=B0=20=D0=BE=D0=B6=D0=B8=D0=B4=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=203=20=D1=81=20=D0=B2=D0=BC=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D0=BE=201=20(=D1=81=D0=B5=D0=BA=D1=83=D0=BD=D0=B4=D0=BD?= =?UTF-8?q?=D0=B0=D1=8F=20=D0=B3=D1=80=D0=B0=D0=BD=D1=83=D0=BB=D1=8F=D1=80?= =?UTF-8?q?=D0=BD=D0=BE=D1=81=D1=82=D1=8C=20date=20+%s=20=D0=B4=D0=B0?= =?UTF-8?q?=D0=B2=D0=B0=D0=BB=D0=B0=20=D1=84=D0=BB=D0=B5=D0=B9=D0=BA=202?= =?UTF-8?q?=20=D0=B8=D0=B7=203)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/tests/test_2950_latest_image_revision_gate.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/backend/tests/test_2950_latest_image_revision_gate.py b/backend/tests/test_2950_latest_image_revision_gate.py index af602ef7..ad75bc28 100644 --- a/backend/tests/test_2950_latest_image_revision_gate.py +++ b/backend/tests/test_2950_latest_image_revision_gate.py @@ -111,7 +111,10 @@ def test_newer_revision_passes(repo: dict[str, object]) -> None: 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") + # timeout=3: дедлайн считается целыми секундами (`date +%s`), и при 1 с первый + # опрос может прийти уже за дедлайном — гард честно падает сразу, а тест ждёт + # строки ожидания. 3 с при паузе 0.2 с дают её гарантированно. + r = _run(repo, str(repo["c1"]), "backend", timeout="3") assert r.returncode == 1, r.stdout + r.stderr assert "::error::" in r.stderr and "отстаёт" in r.stderr assert "жду билд предшественника" in r.stdout, "гард обязан ЖДАТЬ, а не падать сразу" -- 2.45.3