-
Notifications
You must be signed in to change notification settings - Fork 0
498 lines (468 loc) · 22.1 KB
/
build-docker-images.yml
File metadata and controls
498 lines (468 loc) · 22.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
name: Docker - oficjalne obrazy
# =============================================================================
# Flow publikacji obrazow Docker (staging-tag + promotion pattern)
# =============================================================================
#
# Problem ktory to rozwiazuje:
# Trivy (lub jakikolwiek skaner) odpalony PO pushu jest bezuzyteczny jako
# gate — jesli znajdzie CRITICAL CVE, obraz juz jest w Docker Hub pod
# kanonicznym tagiem (np. :2025.12.1, :latest) i uzytkownicy/deployment
# moga go pullnac, zanim zobaczymy raport.
#
# Jak dzialamy:
# Faza 1 — Build → push do STAGING TAGU (np. ":sha-abc1234")
# Bake pushuje obrazy do Docker Hub, ale pod tagiem *niekanonicznym*,
# unikalnym per-build (SHA commitu). Zaden deployment ani dokumentacja
# nie odwoluje sie do tych tagow — sa technicznie publiczne, ale w
# praktyce "ukryte" (nikt nie pullnie `iplweb/bpp_appserver:sha-abc1234`
# bo nikt tego tagu nie zna). Staging tag umozliwia Trivy skanowanie
# obrazu tak, jak bylby po release.
#
# Faza 2 — Trivy gate (TYLKO na master)
# Skan staging tagu. Polityka:
# • CRITICAL (z dostepnym fix-em) → HARD FAIL. Promocja sie nie
# wykona, wiec kanoniczny tag nigdy nie powstanie w rejestrze.
# • HIGH → report-only w GitHub Step Summary. Dominuje szum
# (DoS w build-time libach, test fixtures z privnymi kluczami w
# bibliotekach pythonowych). Blokowanie HIGH spowodowalo by
# stale czerwone master bez dzialania po stronie kodu.
# • --ignore-unfixed → pomijamy CVE bez fixa (nic nie mozemy
# zrobic, nie blokujemy release-u w nieskonczonosc).
# Feature branche NIE sa skanowane — to +3.5 min na pipeline i nie
# dotycza release-u (pushuja do branch-specific tagu, ktory jest
# jawnie tymczasowy).
#
# Faza 3 — Promote staging → canonical tag
# Po przejsciu Trivy (na master) lub bezwarunkowo (na feature branch),
# `docker buildx imagetools create` *kopiuje manifest* z staging tagu
# pod kanoniczny tag — to jest operacja na metadanych rejestru, bez
# rebuildu, bez re-pushu warstw. Koszt ~sekunda per obraz.
# Na master dodatkowo ustawia tag ":latest".
#
# Wazne zastrzezenie o rejestrze Docker Hub:
# Raz pushniety digest zyje w rejestrze do momentu recznego DELETE przez
# API Docker Hub. Staging-tag pattern chroni przed *odkryciem* zepsutego
# obrazu przez standardowe kanaly (tag), nie przed jego *istnieniem*.
# Dla pelnej izolacji trzeba by uzywac prywatnego staging registry i
# kopiowac tylko czyste obrazy do public — to znaczaca zmiana
# infrastruktury i nie jest potrzebne w BPP, gdzie uzytkownicy pullnuja
# po wersji.
#
# Cleanup staging tagow:
# Obecnie NIE sa usuwane po promocji — akumuluja sie w Docker Hub jako
# ":sha-XXXXXXX". Jesli kumulacja stanie sie problemem, mozna:
# a) dodac krok po promocji ktory DELETE-uje staging tag przez API
# Docker Hub (wymaga PAT z uprawnieniem do delete)
# b) uruchamiac periodyczny cron czyszczacy tagi starsze niz N dni
# Aktualnie: akceptujemy kumulacje, bo daje to mozliwosc rollbacku po
# SHA w razie potrzeby.
#
# =============================================================================
on:
push:
branches:
- master
- main
# feature/**, fix/**, hotfix/** removed - build only on [docker-build] flag
pull_request:
# Buduje na każdy push do PR-a (oraz na otwarcie/reopen).
types: [opened, synchronize, reopened]
workflow_dispatch:
# Ręczne wywołanie z GUI GitHub lub przez
# `gh workflow run build-docker-images.yml --ref <branch>`.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
check-flag:
# Guard job decyduje czy budowac obraz Docker.
# - master/main push: buduj zawsze (release flow, dowolny actor)
# - workflow_dispatch: buduj zawsze (manual override, dowolny actor)
# - PR push / feature push: buduj tylko gdy:
# • actor=mpasternak
# • ORAZ commit message zawiera [docker-build]
# (case-insensitive, w subject lub body)
# W przeciwnym razie skip — aby nie palic
# Docker Cloud minutek na kazdy feature/fix
# branch. Mozna wymusic build recznie przez
# `gh workflow run build-docker-images.yml`.
# Plus dedupe: push do branchu z otwartym PR-em → skip (PR run obsluzy).
#
# Budowanie ad-hoc dowolnego branchu jako inny user:
# gh workflow run build-docker-images.yml --ref <branch>
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
outputs:
should_build: ${{ steps.check.outputs.should_build }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0 # Need full history for commit messages
- id: check
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REF_NAME: ${{ github.ref_name }}
EVENT_NAME: ${{ github.event_name }}
REPO: ${{ github.repository }}
ACTOR: ${{ github.actor }}
GIT_SHA: ${{ github.sha }}
run: |
# Dedupe: push event na branchu z otwartym PR-em jest duplikatem
# pull_request eventu dla tego samego commita. Pomijamy push run,
# zeby nie budowac i nie pushowac tego samego SHA dwa razy do
# Docker Cloud (~7 min build + transfer do rejestru kazdorazowo).
# pull_request event bedzie tagowal obraz <PR#>-merge.
if [ "$EVENT_NAME" = "push" ] \
&& [ "$REF_NAME" != "master" ] \
&& [ "$REF_NAME" != "main" ]; then
PR=$(gh pr list --head "$REF_NAME" --state open \
--repo "$REPO" \
--json number --jq '.[0].number // empty')
if [ -n "$PR" ]; then
echo "should_build=false" >> "$GITHUB_OUTPUT"
echo "::notice::Pomijam push run — PR #${PR} obsluzy ten commit (tag ${PR}-merge)"
exit 0
fi
fi
# Zawsze: master/main push (release) i workflow_dispatch (manual)
if [ "$EVENT_NAME" = "push" ] \
&& { [ "$REF_NAME" = "master" ] || [ "$REF_NAME" = "main" ]; }; then
echo "should_build=true" >> "$GITHUB_OUTPUT"
echo "::notice::Docker build — push na ${REF_NAME} (release flow)"
exit 0
fi
if [ "$EVENT_NAME" = "workflow_dispatch" ]; then
echo "should_build=true" >> "$GITHUB_OUTPUT"
echo "::notice::Docker build — workflow_dispatch (manual override)"
exit 0
fi
# Pozostale (PR event, feature push bez PR) — sprawdzamy flage
# [docker-build] w commit message (case-insensitive).
# Budujemy tylko gdy:
# - actor to mpasternak
# - ORAZ commit message zawiera [docker-build]
if [ "$ACTOR" != "mpasternak" ]; then
echo "should_build=false" >> "$GITHUB_OUTPUT"
echo "::notice::Pomijam Docker build — actor=${ACTOR} != mpasternak (flag check skipped)"
exit 0
fi
# Pobierz pelny commit message (subject + body)
COMMIT_MSG=$(git log -1 --format=%B "$GIT_SHA")
# Sprawdz flage [docker-build] (case-insensitive)
if echo "$COMMIT_MSG" | grep -qi "\[docker-build\]"; then
echo "should_build=true" >> "$GITHUB_OUTPUT"
echo "::notice::Docker build — znaleziono flage [docker-build] w commit message"
else
echo "should_build=false" >> "$GITHUB_OUTPUT"
echo "::notice::Pomijam Docker build — brak flagi [docker-build] w commit message"
echo "::notice::Aby wymusic build, dodaj [docker-build] do commit message lub uruchom: gh workflow run build-docker-images.yml --ref ${REF_NAME}"
fi
docker:
needs: check-flag
if: needs.check-flag.outputs.should_build == 'true'
runs-on: ubuntu-latest
steps:
- name: Log in to Docker Hub
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ vars.DOCKER_USER }}
password: ${{ secrets.DOCKER_PAT }}
- name: Set up Docker Buildx Action
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
with:
version: "lab:latest"
driver: cloud
endpoint: "iplweb/bpp"
cache-binary: true
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Get version from Makefile
id: version
run: echo "version=$(grep '^DOCKER_VERSION=' Makefile | cut -d'=' -f2)" >> $GITHUB_OUTPUT
- name: Compute Docker tags (staging + final)
id: tag
env:
REF_NAME: ${{ github.ref_name }}
# head_ref jest ustawione tylko dla pull_request eventów — to nazwa
# gałęzi PR-a (czyli "feature/...", a nie "<PR#>/merge" jak ref_name).
# Sanityzujemy przed użyciem (sed/tr poniżej) — tag nie trafia
# bezpośrednio do shella jako kod, tylko jako argument do imagetools.
HEAD_REF: ${{ github.head_ref }}
BASE_VERSION: ${{ steps.version.outputs.version }}
GIT_SHA: ${{ github.sha }}
EVENT_NAME: ${{ github.event_name }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
# -----------------------------------------------------------------
# Dwa-trzy rodzaje tagow dla kazdego obrazu (patrz naglowek pliku):
#
# staging_tag: niekanoniczny, unikalny per build. Bake pushuje
# TU przed skanem. Format: "sha-<short_sha>".
# Uzytkownicy go nie znaja — zadne docs, zadne
# deployment scripty nie odwoluja sie do sha-*.
#
# final_tag: kanoniczny tag — tworzony DOPIERO po skanie przez
# promocje manifestu (imagetools create).
# • master → DOCKER_VERSION z Makefile (np. "2025.12.1")
# • pull_request → "<PR#>-merge" (jesli jest PR)
# • feature/fix → sanitized branch name
#
# branch_tag: DODATKOWY alias = sanityzowana nazwa source brancha.
# Ustawiany TYLKO dla pull_request eventow, zeby tester
# mogl pullowac obraz po nazwie brancha (intuicyjne)
# zamiast pamietac numer PR. Dla feature/fix push
# brancha bez PR-a final_tag = branch_tag, wiec nie
# ma sensu duplikowac.
#
# tag_latest: czy dodatkowo pushowac ":latest" po promocji
# (tylko na master).
# -----------------------------------------------------------------
SHORT_SHA="${GIT_SHA:0:7}"
STAGING_TAG="sha-${SHORT_SHA}"
echo "staging_tag=${STAGING_TAG}" >> "$GITHUB_OUTPUT"
if [ "$REF_NAME" = "master" ]; then
echo "final_tag=${BASE_VERSION}" >> "$GITHUB_OUTPUT"
echo "tag_latest=true" >> "$GITHUB_OUTPUT"
echo "branch_tag=" >> "$GITHUB_OUTPUT"
elif [ "$EVENT_NAME" = "pull_request" ] && [ -n "$PR_NUMBER" ]; then
echo "final_tag=${PR_NUMBER}-merge" >> "$GITHUB_OUTPUT"
echo "tag_latest=false" >> "$GITHUB_OUTPUT"
BRANCH_TAG=$(echo "$HEAD_REF" \
| sed 's/[^a-zA-Z0-9._-]/-/g' \
| tr '[:upper:]' '[:lower:]')
echo "branch_tag=${BRANCH_TAG}" >> "$GITHUB_OUTPUT"
else
BRANCH_TAG=$(echo "$REF_NAME" \
| sed 's/[^a-zA-Z0-9._-]/-/g' \
| tr '[:upper:]' '[:lower:]')
echo "final_tag=${BRANCH_TAG}" >> "$GITHUB_OUTPUT"
echo "tag_latest=false" >> "$GITHUB_OUTPUT"
echo "branch_tag=" >> "$GITHUB_OUTPUT"
fi
# -----------------------------------------------------------------
# FAZA 1: Build → push do staging tagu
# -----------------------------------------------------------------
# Przekazujemy DOCKER_VERSION=${staging_tag} i TAG_LATEST=false,
# zeby bake pushnal TYLKO do "sha-XXXXXXX" (bez ":latest", bez
# kanonicznego tagu). Reszta parametrow bake niezmieniona.
# -----------------------------------------------------------------
- name: Build and push to staging tag
env:
GIT_SHA: ${{ github.sha }}
DOCKER_VERSION: ${{ steps.tag.outputs.staging_tag }}
TAG_LATEST: "false"
PUSH: "true"
# Wynikiem ekspresji jest literal "release" albo "dev" — nie ma
# tu untrusted input z github.event.*. Ref name jest weryfikowane
# przez równość z literałem, więc nie ma ryzyka injection.
BPP_BUILD_FLAVOR: ${{ github.ref_name == 'master' && 'release' || 'dev' }}
# Kanoniczny tag finalny — wpisany do obrazu jako BPP_IMAGE_TAG
# ENV, żeby stopka dla dev buildów wyświetlała "119-merge" obok
# commit SHA. Wartość pochodzi ze stepa `tag` powyżej.
BPP_IMAGE_TAG: ${{ steps.tag.outputs.final_tag }}
# Alias nazwy brancha (np. "feature-nowe-zglos-publikacje") —
# ustawiany przez step `tag` tylko dla pull_request eventów,
# pusty inaczej. Stopka pokaże ten alias obok PR#-merge.
BPP_BRANCH_TAG: ${{ steps.tag.outputs.branch_tag }}
run: |
docker buildx bake \
base appserver workerserver \
beatserver authserver denorm-queue \
--file docker-bake.hcl \
--set "base.args.GIT_SHA=${GIT_SHA}" \
--set "base.args.BPP_BUILD_FLAVOR=${BPP_BUILD_FLAVOR}" \
--set "base.args.BPP_IMAGE_TAG=${BPP_IMAGE_TAG}" \
--set "base.args.BPP_BRANCH_TAG=${BPP_BRANCH_TAG}" \
--set '*.platform=linux/amd64' \
--allow=fs.read=/tmp \
--allow=fs.write=/tmp
# -----------------------------------------------------------------
# FAZA 2: Trivy gate (TYLKO na master)
# -----------------------------------------------------------------
# Skanuje staging tag. CRITICAL z dostepnym fix-em → exit 1,
# promocja sie nie wykona (krok "Promote" ma domyslnie
# "if: success()"). HIGH → zapis do Step Summary, nie blokuje.
#
# Skipowane sciezki (znane false-positivy w zaleznosciach):
# • autobahn/wamp/cryptosign.py — przykladowy klucz w docstringu
# • slapdtest/certs/ — test fixtures python-ldap
# Jesli pojawi sie nowa biblioteka z podobnymi false-positivami,
# dopisz tutaj z komentarzem wyjasniajacym dlaczego to nie jest
# prawdziwy sekret.
# -----------------------------------------------------------------
- name: Trivy CRITICAL gate (master only)
if: github.ref_name == 'master'
env:
STAGING_TAG: ${{ steps.tag.outputs.staging_tag }}
run: |
set -u
images=(
iplweb/bpp_base
iplweb/bpp_appserver
iplweb/bpp_workerserver
iplweb/bpp_beatserver
iplweb/bpp_authserver
iplweb/bpp_denorm_queue
)
# Cache Trivy vulndb miedzy skanami (6 obrazow x ~200MB db = znaczna
# oszczednosc, pobiera sie raz do volume, reszta skanow reuse-uje).
mkdir -p /tmp/trivy-cache
TRIVY_COMMON_ARGS=(
--severity CRITICAL
--exit-code 1
--ignore-unfixed
--no-progress
--skip-files "**/autobahn/wamp/cryptosign.py"
--skip-dirs "**/slapdtest"
)
FAILED_IMAGES=()
for img in "${images[@]}"; do
echo "::group::Trivy CRITICAL scan ${img}:${STAGING_TAG}"
if ! docker run --rm \
-v /var/run/docker.sock:/var/run/docker.sock \
-v /tmp/trivy-cache:/root/.cache/trivy \
aquasec/trivy:latest image \
"${TRIVY_COMMON_ARGS[@]}" \
"${img}:${STAGING_TAG}"; then
FAILED_IMAGES+=("${img}")
fi
echo "::endgroup::"
done
if [ ${#FAILED_IMAGES[@]} -gt 0 ]; then
{
echo "## ❌ Trivy CRITICAL gate failed"
echo ""
echo "Znaleziono CRITICAL CVE (z dostepnym fix-em) w obrazach:"
echo ""
for img in "${FAILED_IMAGES[@]}"; do
echo "- \`${img}:${STAGING_TAG}\`"
done
echo ""
echo "**Promocja do kanonicznych tagow ZABLOKOWANA.**"
echo "Staging tag \`:${STAGING_TAG}\` pozostaje w rejestrze pod SHA"
echo "(do diagnostyki), ale wersja i \`:latest\` nie zostana utworzone."
echo ""
echo "Co zrobic:"
echo "1. Przejrzyj log powyzej — Trivy wypisuje CVE ID i pakiet"
echo "2. Zaktualizuj bazowy obraz / zaleznosc z fixem"
echo "3. Ponow push do mastera"
} >> "$GITHUB_STEP_SUMMARY"
exit 1
fi
- name: Trivy HIGH report (master only, report-only)
# Pokaz liste HIGH w Step Summary, ale nie blokuj. To jest
# pomocna informacja o "dlugu CVE" — pozwala zdecydowac co bumpnac
# przy nastepnej aktualizacji zaleznosci, bez zatrzymywania release-u.
if: github.ref_name == 'master'
continue-on-error: true
env:
STAGING_TAG: ${{ steps.tag.outputs.staging_tag }}
run: |
set -u
images=(
iplweb/bpp_base
iplweb/bpp_appserver
iplweb/bpp_workerserver
iplweb/bpp_beatserver
iplweb/bpp_authserver
iplweb/bpp_denorm_queue
)
{
echo "## Trivy HIGH CVE report (informacyjny, nie blokuje)"
echo ""
} >> "$GITHUB_STEP_SUMMARY"
for img in "${images[@]}"; do
echo "::group::Trivy HIGH scan ${img}:${STAGING_TAG}"
REPORT=$(docker run --rm \
-v /var/run/docker.sock:/var/run/docker.sock \
-v /tmp/trivy-cache:/root/.cache/trivy \
aquasec/trivy:latest image \
--severity HIGH \
--exit-code 0 \
--ignore-unfixed \
--no-progress \
--skip-files "**/autobahn/wamp/cryptosign.py" \
--skip-dirs "**/slapdtest" \
--format table \
"${img}:${STAGING_TAG}" 2>&1 || true)
echo "$REPORT"
{
echo "<details><summary><code>${img}:${STAGING_TAG}</code></summary>"
echo ""
echo '```'
echo "$REPORT"
echo '```'
echo ""
echo "</details>"
echo ""
} >> "$GITHUB_STEP_SUMMARY"
echo "::endgroup::"
done
# -----------------------------------------------------------------
# FAZA 3: Promote staging tag → canonical tag(s)
# -----------------------------------------------------------------
# docker buildx imagetools create -t NEW SOURCE:
# kopiuje manifest z SOURCE pod tag NEW w tym samym rejestrze.
# Nie rebuilduje, nie pushuje warstw — tylko zapisuje manifest
# z referencja do istniejacych (juz pushnietych) layers.
# Koszt: 1-2 sek per tag.
#
# if: success() (domyslne) — na master wykona sie tylko jesli Trivy
# gate przeszedl. Na feature branch zawsze (tam skan sie nie uruchomil,
# poprzednie kroki byly "if: github.ref_name == 'master'" i zakonczyly
# sie jako "skipped" → success=true dla nastepnych).
# -----------------------------------------------------------------
- name: Promote staging tag to canonical tag(s)
env:
STAGING_TAG: ${{ steps.tag.outputs.staging_tag }}
FINAL_TAG: ${{ steps.tag.outputs.final_tag }}
TAG_LATEST: ${{ steps.tag.outputs.tag_latest }}
# Alias-tag dla source brancha PR-a (pusty dla master/non-PR);
# patrz komentarz w stepie "Compute Docker tags".
BRANCH_TAG: ${{ steps.tag.outputs.branch_tag }}
run: |
set -eu
images=(
iplweb/bpp_base
iplweb/bpp_appserver
iplweb/bpp_workerserver
iplweb/bpp_beatserver
iplweb/bpp_authserver
iplweb/bpp_denorm_queue
)
echo "::notice::Promocja: ${STAGING_TAG} → ${FINAL_TAG} (latest=${TAG_LATEST}, branch=${BRANCH_TAG:-})"
for img in "${images[@]}"; do
echo "::group::Promote ${img}: ${STAGING_TAG} → ${FINAL_TAG}"
docker buildx imagetools create \
-t "${img}:${FINAL_TAG}" \
"${img}:${STAGING_TAG}"
if [ "$TAG_LATEST" = "true" ]; then
echo "→ also tagging :latest"
docker buildx imagetools create \
-t "${img}:latest" \
"${img}:${STAGING_TAG}"
fi
# Tester pullujący po nazwie brancha dostaje świeży obraz
# (PR-owy build re-promuje ten alias przy każdym pushu).
if [ -n "${BRANCH_TAG:-}" ] && [ "$BRANCH_TAG" != "$FINAL_TAG" ]; then
echo "→ also tagging :${BRANCH_TAG}"
docker buildx imagetools create \
-t "${img}:${BRANCH_TAG}" \
"${img}:${STAGING_TAG}"
fi
echo "::endgroup::"
done
{
echo "## ✅ Promoted staging → canonical"
echo ""
echo "Staging tag: \`${STAGING_TAG}\`"
echo "Final tag: \`${FINAL_TAG}\`"
if [ "$TAG_LATEST" = "true" ]; then
echo "Also: \`:latest\`"
fi
if [ -n "${BRANCH_TAG:-}" ] && [ "$BRANCH_TAG" != "$FINAL_TAG" ]; then
echo "Branch alias: \`:${BRANCH_TAG}\`"
fi
} >> "$GITHUB_STEP_SUMMARY"