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