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
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:
parent
1782dae0e2
commit
54889a956b
4 changed files with 275 additions and 0 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
142
backend/tests/test_2950_latest_image_revision_gate.py
Normal file
142
backend/tests/test_2950_latest_image_revision_gate.py
Normal 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
|
||||
70
scripts/check-latest-image-revision.sh
Executable file
70
scripts/check-latest-image-revision.sh
Executable 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
|
||||
Loading…
Add table
Reference in a new issue