-
Notifications
You must be signed in to change notification settings - Fork 604
387 lines (363 loc) · 18.3 KB
/
Copy pathpublish-image.yml
File metadata and controls
387 lines (363 loc) · 18.3 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
name: Publicar imagem Docker (GHCR)
# Builda as imagens GENÉRICAS que o self-hoster instala e publica no GitHub
# Container Registry. O leigo puxa no VPS (não builda lá). Roda nos runners do
# GitHub, então o build pesado nunca cai no VPS do usuário.
#
# São TRÊS imagens, e a razão de o worker e o scheduler estarem aqui é um defeito
# real: o serviço `worker` não tinha `image:` no compose de produção, só `build:`.
# Serviço build-only é pulado por `docker compose pull` e imune a `up -d` sem
# `--build` — ele era construído na VPS de cada cliente, no install, e NENHUM
# update.sh jamais o reconstruiu. O runtime do agente de IA congelava no código
# do dia da instalação. Doutrina: docs/doctrine/packaging.md, invariante 1.
on:
push:
tags: ["v*"]
branches: ["main"]
# NÃO acrescente `release: types: [published]` aqui. Ele já esteve, e o efeito
# foi medido na v1.3.0: `gh release create` empurra a tag E publica a release,
# então o MESMO commit foi construído DUAS vezes, com 5 minutos de diferença.
#
# 19:53:04 evento=push v1.3.0 -> publicou 1.3.0, 1.3 e stable
# 19:58:05 evento=release v1.3.0 -> reconstruiu e MOVEU 1.3.0 e 1.3,
# sem mover stable (a condição exige push)
#
# Duas consequências, e a segunda é a grave:
#
# 1. `stable` e `1.3.0` ficaram em digests DIFERENTES (mesmo `revision`
# 9bd59e9 e mesmo `version`, builds distintos) — quem comparasse os dois
# concluiria, com razão, que são artefatos diferentes.
# 2. A tag de VERSÃO foi movida depois de publicada. A doutrina inteira se
# apoia em "instalação de cliente aponta para número de versão, nunca para
# tag móvel" (docs/doctrine/packaging.md, invariante 3) — e era o nosso
# próprio workflow movendo o número. Republicar uma release antiga (editar
# e salvar) reconstruiria e moveria aquela versão de novo.
#
# O gatilho de push da tag já cobre o fluxo real: a tag é sempre empurrada,
# com ou sem release. Vigiado por tests/unit/packaging-artefato-do-cliente.test.ts.
# Em PR a imagem é CONSTRUÍDA e não publicada. Sem isto, nada no PR consegue
# revelar que a mudança quebra a imagem — foi assim que um bump de `next`
# passou por `verify`, `build-and-size`, `invariants` e `e2e` (os quatro
# obrigatórios) e derrubou o build do Dockerfile na `main`: o `next build`
# dentro da imagem não enxerga `tests/` (`.dockerignore`), e a partir do
# next 16.3 ele typecheca os `*.test.ts` colocados. O artefato que o
# self-hoster instala era o único sem gate.
pull_request:
workflow_dispatch: {}
env:
REGISTRY: ghcr.io
jobs:
# Nenhuma tag publica sem estar CONTIDA na `main`.
#
# `on: push: tags: ["v*"]` reage a tag de QUALQUER ramo, e nenhuma das três
# condições do canal `stable` (mais abaixo) olha procedência — nem o
# `type=semver`, que publica `X.Y.Z` do mesmo jeito. A branch protection não
# cobre isto: ela vale para a branch `main`, não para refs de tag.
#
# E o alcance disso é o parque inteiro: `hostgator-setup-kit/agent.sh` oferece
# a MAIOR tag `v*` a toda VPS instalada, sem olhar de onde ela veio. Este repo
# já teve na árvore local uma tag `v1.4.0` de um FORK — ela existe hoje
# renomeada para `jmpo/v1.4.0` justamente para não colidir com a numeração
# daqui.
a-tag-veio-da-main:
runs-on: ubuntu-latest
# Sem o bloco, o token herda o default do REPOSITÓRIO — configuração que vive
# fora do repo. Mesma razão do `permissions: {}` de `imagens-ok`.
permissions:
contents: read
steps:
- name: A tag está contida na main?
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
# Sem `if:` no job, de propósito: job pulado deixa `build-and-push`
# pulado também, e `imagens-ok` (que roda com `always()`) lê `skipped`
# como reprovação. Em PR e em push de branch este job RODA e sai zero.
if [ "${GITHUB_REF_TYPE}" != "tag" ]; then
echo "não é push de tag (${GITHUB_REF_TYPE}) — nada a conferir"
exit 0
fi
estado=$(gh api "repos/${GITHUB_REPOSITORY}/compare/main...${GITHUB_SHA}" --jq .status)
echo "compare main...${GITHUB_SHA} → ${estado}"
# `identical` = a tag é o topo da main; `behind` = está atrás dela, ou
# seja, contida. `ahead` e `diverged` são commit que a main não tem.
case "$estado" in
identical|behind) echo "ok: o commit da tag está contido na main" ;;
*)
echo "::error::A tag ${GITHUB_REF_NAME} aponta para um commit que a main NÃO contém (${estado})."
echo "::error::Publicar isso entregaria ao parque instalado código que nunca passou pelo CI da main."
exit 1
;;
esac
build-and-push:
needs: [a-tag-veio-da-main]
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
strategy:
# fail-fast desligado: se o worker quebrar, ainda quero saber se o app
# também quebrou. Com ele ligado, o primeiro erro esconde os outros dois e
# o conserto vira uma rodada por imagem.
fail-fast: false
matrix:
include:
- name: deskcommcrm
dockerfile: Dockerfile
title: DeskcommCRM
- name: deskcomm-worker
dockerfile: Dockerfile.worker
title: DeskcommCRM worker
- name: deskcomm-scheduler
dockerfile: Dockerfile.scheduler
title: DeskcommCRM scheduler
steps:
- uses: actions/checkout@v7
- name: Log in to GHCR
if: github.event_name != 'pull_request'
uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# A versão que a imagem vai reportar em /api/v1/health. Numa tag v1.2.3 é
# "1.2.3"; fora de tag, o SHA curto — que ainda responde "o que está
# rodando aqui?", que é a pergunta que o campo existe para responder.
- name: Resolver APP_VERSION
id: ver
run: |
if [ "${GITHUB_REF_TYPE}" = "tag" ]; then
echo "value=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
else
echo "value=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
fi
- name: Docker metadata (tags/labels)
id: meta
uses: docker/metadata-action@v6
with:
images: ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ matrix.name }}
tags: |
type=ref,event=branch
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
# `enable={{is_default_branch}}` NÃO basta: o metadata-action o
# considera verdadeiro também num push de TAG, então toda release
# movia `latest` junto. Isso apagava a distinção que a doutrina
# inteira depende — `latest` = topo da main, `stable` = última
# release — e fazia `latest` oscilar entre os dois.
type=raw,value=latest,enable=${{ github.ref_type == 'branch' && github.ref_name == 'main' }}
# `stable` NÃO sai daqui, e a razão é a issue #488. Ele já saiu: era
# mais um `type=raw` nesta lista, e por isso CADA imagem movia o
# canal sozinha, ao terminar, sem saber se as irmãs conseguiram.
# Medido no corte da v1.12.0 — o job do app morreu em `Set up
# Buildx`, antes de compilar coisa alguma, enquanto worker e
# scheduler seguiram e moveram o ponteiro:
#
# deskcommcrm 1.12.0 404
# deskcommcrm stable sha256:0235d02b… ← 1.11.0
# deskcomm-worker stable sha256:66c7bde4… ← 1.12.0
# deskcomm-scheduler stable sha256:ac6b87cc… ← 1.12.0
#
# Quem instalasse por `stable` naquela janela recebia app 1.11.0 com
# worker e scheduler 1.12.0. `fail-fast: true` não resolveria: quando
# o app falhou, as irmãs JÁ tinham publicado e JÁ tinham movido o
# canal — abortá-las não desfaz o que foi publicado.
#
# Publicar por NÚMERO é o que continua aqui, e é seguro: `1.12.0`
# pode existir para uma imagem e não para outra sem prejuízo, porque
# ninguém instala por um número que o instalador não escreveu. Mover
# o CANAL é ato do job `promover-stable`, lá embaixo, quando o
# conjunto está completo.
labels: |
org.opencontainers.image.title=${{ matrix.title }}
- name: Set up Buildx
uses: docker/setup-buildx-action@v4
- name: Build e push (imagem genérica — sem segredos)
uses: docker/build-push-action@v7
with:
context: .
file: ${{ matrix.dockerfile }}
# linux/amd64: o VPS HostGator e a imagem do WAHA são amd64.
platforms: linux/amd64
# PR de fork não tem permissão de escrita no GHCR, e publicar imagem
# de código não revisado seria pior que não gatear. Em PR: constrói
# (que é o gate) e descarta.
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
APP_VERSION=${{ steps.ver.outputs.value }}
# Cache por imagem: as três têm camadas diferentes e um escopo único
# faria uma sobrescrever o cache da outra a cada run.
cache-from: type=gha,scope=${{ matrix.name }}
cache-to: type=gha,mode=max,scope=${{ matrix.name }}
# Sem build-args de segredo: a imagem é genérica (NEXT_PUBLIC_* viram
# placeholder; valores reais entram em runtime no VPS do usuário).
# A imagem do app CONSTRÓI e MORRE ao subir? Este job existe porque isso
# aconteceu: `next@16.3.1` passou a resolver `@swc/helpers` por ESM, o file
# tracing do `output: standalone` levou 2 dos 108 arquivos do pacote, e o
# container entrou em crashloop com MODULE_NOT_FOUND. A imagem construiu,
# `imagens-ok` ficou verde, ela foi publicada — e o defeito só apareceu no
# `docker compose up` da VPS, com a produção fora do ar.
#
# "Constrói" nunca foi prova de "sobe". O gate mede o boot: erguer o
# container com env de mentira e exigir que o Next chegue a "Ready". Um 500
# depois disso é esperado (não há Supabase aqui) e não reprova — o que
# reprova é o processo não chegar a servir.
imagem-do-app-sobe:
needs: [a-tag-veio-da-main]
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v7
- name: Set up Buildx
uses: docker/setup-buildx-action@v4
- name: Build local da imagem do app (sem push)
uses: docker/build-push-action@v7
with:
context: .
file: Dockerfile
platforms: linux/amd64
push: false
load: true
tags: deskcomm-smoke:pr
build-args: |
APP_VERSION=smoke
# Mesmo escopo de cache do build-and-push: as camadas são idênticas,
# então na prática este job reaproveita aquele build em vez de repeti-lo.
cache-from: type=gha,scope=deskcommcrm
- name: O container chega a servir?
run: |
set -euo pipefail
docker run -d --name smoke -p 3000:3000 \
-e NEXT_PUBLIC_SUPABASE_URL=https://exemplo.supabase.co \
-e NEXT_PUBLIC_SUPABASE_ANON_KEY=chave-de-mentira \
-e SUPABASE_SERVICE_ROLE_KEY=chave-de-mentira \
-e SUPABASE_DB_URL=postgres://u:p@127.0.0.1:5432/postgres \
-e SENTRY_DSN=off \
deskcomm-smoke:pr
# Espera o boot; falha rápido se o processo morreu (crashloop).
pronto=nao
for _ in $(seq 1 40); do
if ! docker ps --filter name=smoke --filter status=running -q | grep -q .; then
echo "::error::o container do app MORREU durante o boot"
docker logs smoke 2>&1 | tail -40
exit 1
fi
if docker logs smoke 2>&1 | grep -qE "Ready in|started server on"; then
pronto=sim
break
fi
sleep 3
done
if [ "$pronto" != "sim" ]; then
echo "::error::o app não chegou a servir em 120s"
docker logs smoke 2>&1 | tail -40
exit 1
fi
# A assinatura exata do defeito que originou este gate — explícita,
# para o log dizer o que houve em vez de só "não subiu".
if docker logs smoke 2>&1 | grep -q "MODULE_NOT_FOUND"; then
echo "::error::falta módulo no standalone (o defeito do @swc/helpers)"
docker logs smoke 2>&1 | grep -A5 MODULE_NOT_FOUND | head -20
exit 1
fi
echo "o app chegou a servir:"
docker logs smoke 2>&1 | grep -E "Ready in|Next.js" | head -3
- name: Encerrar
if: always()
run: docker rm -f smoke 2>/dev/null || true
# O CANAL `stable` MOVE AQUI, E SÓ AQUI — depois que o conjunto está completo.
#
# Antes, `stable` era mais um `type=raw` na lista de tags da matriz, e por isso
# CADA imagem movia o canal sozinha ao terminar, sem saber se as irmãs
# conseguiram. Medido no registro público durante o corte da v1.12.0 (issue
# #488), com sonda de DIGEST — a ingênua ("a tag `stable` existe?") devolve 200
# nas três e não prova nada, porque `stable` existe desde a 1.11.0:
#
# deskcommcrm 1.12.0 404
# deskcommcrm stable sha256:0235d02b… <- 1.11.0
# deskcomm-worker stable sha256:66c7bde4… <- 1.12.0
# deskcomm-scheduler stable sha256:ac6b87cc… <- 1.12.0
#
# Quem instalasse por `stable` naquela janela recebia app 1.11.0 com worker e
# scheduler 1.12.0. Os três saem do mesmo repositório e compartilham código;
# rodar dois numa versão e o terceiro noutra é um estado que nenhum teste cobre
# e que ninguém escolheu. O job do app tinha morrido em `Set up Buildx` — antes
# de compilar qualquer coisa, portanto por infraestrutura e não por conteúdo.
#
# `fail-fast: true` na matriz seria o conserto óbvio, e chega tarde: quando o
# app falhou, as irmãs JÁ tinham publicado e JÁ tinham movido o canal. Abortar
# não desfaz o que foi publicado. O que separa os dois atos é publicar por
# NÚMERO sempre (cada imagem, independente — ninguém instala por um número que
# o instalador não escreveu) e mover o CANAL num job final.
promover-stable:
# Sem `always()`, e isso é o ponto: `needs` só libera quando as DUAS entradas
# deram `success`. Com `always()` o canal andaria por cima de uma irmã que
# falhou, que é exatamente o defeito de volta com outra roupa.
needs: [build-and-push, imagem-do-app-sobe]
# As três condições, e cada uma barra um caminho medido. Sem `push`, um
# dispatch numa release ANTIGA faria `stable` REGREDIR, e todo self-hoster no
# default do compose sofreria downgrade silencioso no próximo `up -d` — app
# velho sobre banco já migrado. Sem `tag`, um dispatch numa branch moveria o
# canal. Sem o `v`, uma tag de teste o move (o registro já tem uma
# `quebrada-teste`, que nenhum push na main consegue produzir).
if: github.event_name == 'push' && github.ref_type == 'tag' && startsWith(github.ref_name, 'v')
runs-on: ubuntu-latest
permissions:
# O menor escopo que faz o trabalho: reapontar tag no GHCR. Sem o bloco, o
# GITHUB_TOKEN herdaria o default do REPOSITÓRIO — configuração que vive
# fora do repo, muda num clique e não passa por PR.
packages: write
steps:
- name: Log in to GHCR
uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Apontar `stable` para esta versão, nas três imagens
env:
VERSAO: ${{ github.ref_name }}
run: |
set -euo pipefail
# `imagetools create` REAPONTA o índice que já foi publicado — não
# reconstrói. Reconstruir o mesmo commit dá um digest DIFERENTE (foi o
# que fez `stable` e `1.3.0` divergirem com o mesmo `revision`), e aí o
# canal deixaria de ser o mesmo manifesto que a versão.
dono=$(echo "${GITHUB_REPOSITORY_OWNER}" | tr '[:upper:]' '[:lower:]')
numero="${VERSAO#v}"
for img in deskcommcrm deskcomm-worker deskcomm-scheduler; do
echo "::group::${img}"
docker buildx imagetools create \
--tag "${REGISTRY}/${dono}/${img}:stable" \
"${REGISTRY}/${dono}/${img}:${numero}"
echo "::endgroup::"
done
echo "stable agora aponta para ${numero} nas três imagens"
# Job de fachada: dá UM nome estável para a branch protection exigir. Sem ele,
# o required check teria que listar as três instâncias da matriz pelo nome
# gerado, e acrescentar uma quarta imagem um dia quebraria o gate em silêncio —
# o merge voltaria a passar sem que ninguém tivesse decidido isso.
imagens-ok:
if: always()
runs-on: ubuntu-latest
# Nenhuma permissão: este job só lê o resultado de `needs` e sai. Sem o bloco,
# o GITHUB_TOKEN herdaria o default do REPOSITÓRIO — configuração que vive fora
# do repo, muda num clique e não passa por PR.
#
# A regra e o gate que a impõe vieram do PR #241 (alertas do CodeQL), de um
# contribuidor. O teste dele pegou este job: eu o criei sem o bloco, e o defeito
# só apareceu quando a `main` entrou naquela branch. É o próprio caso que a
# justificativa do teste antecipa — "um job novo nasce sem bloco e herda o
# default de novo".
permissions: {}
needs: [build-and-push, imagem-do-app-sobe]
steps:
- name: Falhar se qualquer imagem não construiu — ou se o app não sobe
run: |
echo "build-and-push: ${{ needs.build-and-push.result }}"
echo "imagem-do-app-sobe: ${{ needs.imagem-do-app-sobe.result }}"
[ "${{ needs.build-and-push.result }}" = "success" ]
[ "${{ needs.imagem-do-app-sobe.result }}" = "success" ]