Deploy pipeline — jak deploy.sh funguje
Technická dokumentace deploy mechanismu. Pravidla autorizace jsou v auto-memory (orthogonal feedback_deploy_authorization.md, feedback_hub_before_prod.md atd.).
Entry point
service:auth | pulse | s60-mail | n8n | badwolf | venom | billit(validováno v deploy.sh:80)env:hub | prodcommit: volitelný — pokud nezadán, použije semasterHEAD na cílovém serveru
Co deploy.sh dělá (v pořadí)
- Validace argumentů — service známé, env hub/prod
- SSH na cílový server — sentinel → hub-alfa nebo prod-alfa (Tailscale)
- Pre-deploy snapshot (ADR-007) — backup
/opt/<service>/a Docker volumes před deployem - Read
deploy.yml— manifest na cílovém serveru/opt/<service>/deploy.yml - Migration check — pokud
deploy.ymlmámigration: yesna top-levelu, blokuje deploy a žádá explicitní povolení (od commit fd378b4) - Git fetch + checkout —
git fetch origin master && git checkout <commit> - Selektivní deploy (od commit c6f980c) — pokud
git diff <last-deployed>..<commit>mění jen jeden subdir (např.billit-web/), spustí se pouze ten container s--no-deps. Tím se nerebuilduje zbytek monorepa. - Build + up —
docker compose build <service> && docker compose up -d <service> - DB migrace — pokud
deploy.ymlmádatabase.migration: typeorm, spustímigration_commandvmigration_container - Health check —
curl <health_url>(z deploy.yml) - Append do
deploy/deploys.log—<timestamp> | <service> | <env> | <commit> | <branch> | <status> | qa=<pass/skip> - Optional QA gate — pokud
QA_GATE=on, spustí/root/projects/qa/qa-gate.sh(jen pro hub)
Deploy.yml — co tam musí být
service: <name>
version: "x.y.z"
containers:
- name: <container-name>
port: <internal-port>
health: /health # nebo /healthz
env_file: .env
build:
context: <dir>
dockerfile: Dockerfile
args: [...] # build-time env
dependencies:
required: [postgresql, redis]
optional: [...]
env_vars:
required: [...]
optional: [...]
database:
type: postgresql
migration: typeorm # auto | typeorm | none | manual
migration_command: "..."
migration_container: "<container>"
domains:
staging: <hub-domain>
production: <prod-domain>
nginx:
routing:
- { path: /api, upstream: api:3200 }
- { path: /, upstream: web:3201 }
Selektivní deploy logika
LAST_DEPLOYED = $(grep "^<service>.*<env>" deploys.log | tail -1 | awk commit)
COMMIT = <target>
if LAST_DEPLOYED && LAST_DEPLOYED != COMMIT:
DIFF_FILES = git diff --name-only LAST_DEPLOYED..COMMIT
if DIFF_FILES match exactly one container's build context:
SELECTIVE_SERVICES = "<that container>"
use docker compose up -d --no-deps SELECTIVE_SERVICES
else:
full deploy (all containers)
Limitace: env-only změny (mimo git) vždy spustí full deploy (commit hash se nezmění, ale .env ano).
Pre-deploy snapshots (ADR-007)
Před každým deployem:
- Snapshot
/opt/<service>/→/var/backups/deploy-snapshots/<service>/<timestamp>/ - Snapshot Docker volumes → tar.gz do stejné složky
- Retention: viz
deploy/snapshot-cleanup.sh
Rollback: bash deploy/rollback.sh <service> <env> <snapshot-timestamp> (viz runbooks/rollback.md).
Deploy guard hook
/root/projects/sentinel/.claude/hooks/deploy-guard.sh — PreToolUse hook na Bash. Blokuje přímé docker compose up/down/restart přes SSH na hub/prod. Nutí deploy.sh.
Deploys log
/root/projects/sentinel/deploy/deploys.log — append-only chronologie:
Sloupce: ISO timestamp, service, env, commit, branch, status, qa flag, optional note: ....
Co dělat při failure
- Build failure → IHNED poslat TODO příslušnému dev agentovi (
feedback_deploy_build_errors.md) - HC failure po deployi → rollback ze snapshotu, pak TODO agentovi
- Migration failure → rollback DB ze snapshotu, blokovat další deploy, TODO agentovi
- Container conflict → manual
docker rm -f, znovu deploy.sh, do log přidatnote: container conflict resolved manually
Známé limity
npm installběží jen kdyžnode_modulesneexistuje (deploy.sh ~ř. 453). Když dev agent přidá novou dependency do package.json, staránode_modulesji nemá → tsc build fail. Workaround před deployem:cd /root/projects/<svc> && npm install --ignore-scripts. TODO: spouštět install i když diff$LAST_DEPLOYED..HEADobsahujepackage.json/package-lock.json(poprvé kouslo u pulse CR-141,@aws-sdk/client-s3).
Jak ověřit, co reálně běží (a čemu nevěřit)
⚠️ „✅ done" v changelogu není důkaz nasazení. 2026-08-01 se ukázalo, že s60-mail měl 36a41f6
zapsaný jako úspěšně nasazený 3× (21. 7. prod, 30. 7. hub + prod) a přitom na hubu i produkci běžel
starší kód — /health vracel prázdné details, teprve po reálném deployi 1. 8. začal vracet
database: up, redis: up. Ověřuj chováním běžícího kódu, ne záznamem.
Příčina: --skip-build + neověřovaný build hash (vyšetřeno 2026-08-01)
Zdroj se rsyncne správně, ale kontejner se recreatuje ze starého image — a nic to nezachytí:
| Řádek | Co dělá | Proč to zakrývá problém |
|---|---|---|
| 830 / 836 | up -d --force-recreate bez --build |
kontejner běží z existujícího image |
| 772 | .deploy-commit se zapíše bezpodmínečně |
marker říká cílový commit, i když se nestavělo |
| 967–968 | source hash local ↔ remote | obojí je ten rsyncnutý zdroj ⇒ vždy shoda |
| 975 / 987 | Build hash se vypíše a uloží, ale žádná podmínka ho netestuje |
jediný ukazatel skutečného artefaktu se ignoruje |
| 1271 / 1282 | deploys.log + changelog: commit + done |
flag --skip-build se nikam nezapíše |
Důkaz — .deploy-info u s60-mail/hub, stejný commit i zdroj, jiný artefakt:
30. 7. 17:40 commit=36a41f6 src_hash=eb412102… dist_hash=9a89ff84…
1. 8. 05:59 commit=36a41f6 src_hash=eb412102… dist_hash=5d9ed069…
Historické .deploy-info jde vytáhnout ze snapshotu:
tar -xzOf /var/backups/deploy-snapshots/<svc>/<env>/<id>/app.tar.gz --wildcards "*/.deploy-info"
⚠️ Pozor: --skip-build je zdokumentovaný workaround na docker race („removal already in progress"),
takže se po něm sahá při re-runu rutinně — a přesně tím se vyrábějí falešná „done".
Opraveno 2026-08-01 — tři pojistky
| # | Pojistka | Kde |
|---|---|---|
| 1 | Detekce stale artefaktu: zdroj se od posl. deploye změnil, ale dist_hash v kontejneru je bitově stejný → FAIL: STALE ARTEFAKT, VERIFY_OK=false |
fáze 7.9 |
| 2 | Zámek na --skip-build: když se zdroj po rsyncu liší od posledního .deploy-info, deploy se zastaví ještě před docker fází (exit 1) |
před fází 6 |
| 3 | Build mode v záznamech: deploys.log i changelog nově píšou build=full / build=skipped |
fáze 8 |
Nový flag --force-skip-build = vědomý override pojistky 2. Použij jen když build prokazatelně
proběhl a selhal až recreate kontejneru (docker race). Pojistka 1 běží i tehdy, takže stale artefakt
neprojde ani přes override.
Hláška VERIFIED nově říká, jestli se build změnil:
VERIFIED (commit + source hash + build se změnil: <starý> → <nový>).
Chování ověřeno na 6 kombinacích hashů — poplach padne jen na kombinaci
„zdroj změněn + build beze změny"; první deploy, nezměněný re-deploy i nezjistitelný dist_hash projdou.
Dvě nezávislé kontroly (druhá doplněna 2026-08-01 po auditu)
Kontrola build hashe má slepé místo: služby bez /app/dist (frontendy venom, billit-web)
mají dist_hash prázdný, takže je neochrání. Proto přibyla kontrola stáří image.
| Kontrola 1 — build hash | Kontrola 2 — stáří image | |
|---|---|---|
| Co porovnává | dist_hash proti předchozímu .deploy-info |
čas vzniku image proti času předchozího deploye |
| Sepne když | zdroj změněn + dist_hash beze změny |
zdroj změněn + image starší než předchozí deploy |
| Slepá u | služeb bez /app/dist (frontendy) |
— funguje všude |
| Reakce | FAIL | FAIL, pokud je kontrola 1 slepá; jinak jen varování |
Kontrola 2 je záměrně jen varování tam, kde kontrola 1 funguje: src_hash zahrnuje i soubory,
které se do Docker build kontextu nekopírují (změna mimo COPY ⇒ image se legitimně nepřestaví),
takže by jinak hlásila falešné poplachy.
Dvě pasti odhalené při venom deployi (2026-08-01)
1. Jméno kontejneru z manifestu ≠ běžící kontejner. deploy.yml uvádí logické jméno
(containers[0].name), ale compose ho u některých služeb pojmenuje jinak — venom má
v manifestu s60-venom, běží jako venom-venom-1. docker exec/inspect pak tiše selže
a obě kontroly běží naprázdno (Build hash: (prázdný), Image vznikl: neznámo) —
tedy přesně u frontendu, kvůli kterému kontrola stáří image vznikla.
Fix: resolvovat přes docker compose ps -q v TARGET_PATH.
⚠️ Test existence musí být docker container inspect, ne docker inspect — to matchuje
i image stejného jména (venom má image i manifest s60-venom), takže by test uspěl,
fallback se nespustil a docker exec by stejně selhal.
2. Prázdný /app/dist nevrací prázdno, ale KONSTANTU.
find /app/dist -name "*.js" | sort | xargs md5sum | md5sum na prázdném vstupu vrátí
886f4202f9e4fea2af611f1642f84a08 (md5 z md5 prázdného řetězce) — pro každou takovou
službu stejnou. Po opravě jména kontejneru by tedy kontrola stale artefaktu u frontendu
se změněným zdrojem viděla „dist beze změny" a shodila zdravý deploy.
Fix: když find nenajde žádné .js, vrátit explicitně unknown → kontrola č. 1 se vypne
a rozhoduje stáří image (kontrola č. 2), jak bylo zamýšleno.
Ověřeno: venom → unknown, badwolf → 350007f2…, auth → e1aaa43c… (shodné s hodnotami,
které hlásily deploye téhož dne ⇒ u backendů beze změny chování).
⚠️ Časové zóny: Docker vrací u kontejnerů …Z, ale u lokálně stavěných image offset …+02:00.
Porovnávat ty řetězce přímo je chyba (sentinel na to 1. 8. při auditu naletěl) — deploy.sh je normalizuje
na epoch přes Python.
Ověřeno na 6 scénářích + kontrolně proti reálné flotile (auth, badwolf, pulse, venom) — dnes by
žádná služba falešný poplach nespustila. Případ auth je hezká ukázka, proč podmínka vyžaduje
i změnu zdroje: jeho image je o 9 dní starší než kontejner, protože se 31. 7. nasazoval týž commit
a Docker legitimně použil cache.
📌 .deploy-commit leží na HOSTU v /opt/<služba>/.deploy-commit, ne uvnitř kontejneru
(WORKDIR bývá /app a soubor se do image nekopíruje):
ssh root@<server> "cat /opt/<služba>/.deploy-commit" # ✅ správně
docker exec <container> cat .deploy-commit # ❌ hlásí „chybí" i u zdravé služby
Sentinel na to 1. 8. naletěl a chvíli mylně hlásil, že u s60-mail a auth chybí verifikace commitu.
Doporučené pořadí ověření po deployi:
deploy.shsám:Commit verification→.deploy-commit+ source hashlocal = remote- chování — endpoint, který se novou verzí prokazatelně mění (u mailu
/health→database/redis) - env se reálně propsala: md5 hodnoty v
/opt/<svc>/.envproti/root/secrets/<svc>/.env.<env> - u guardovaných endpointů obě strany: bez tokenu 401, s tokenem 200 — samotné 401 dokazuje jen zaregistrovanou routu, ne funkční handler
Související dokumenty
runbooks/rollback.md— postup rollbackrunbooks/incident.md— incident handlingdocs/deploy/changelog.md— kompletní deploy historiedocs/deploy/CHANGES.md— ADR rozhodnutí v deploy oblastidocs/deploy/DECISIONS.md— architecture decisions