#!/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