Files
DeskcommCRM/.github/workflows/publish-image.yml
T
Rafael MelgaçoandClaude Opus 5 648df22913 fix(ci): dois gatilhos para a mesma tag — o stable da 1.3.0 aponta para outro build
Fui apagar a tag de ensaio do GHCR e a listagem mostrou o que ninguém tinha ido
olhar: `stable` e `1.3.0` são versions com IDs DIFERENTES nos três pacotes.

    deskcommcrm   1.3.0  sha256:fc10b029e326   stable  sha256:c4bc70b606c8
    worker        1.3.0  sha256:81e5af567cc8   stable  sha256:3fe292cad2bd
    scheduler     1.3.0  sha256:4396263ba807   stable  sha256:a0d5c3ad2296

Eu havia registrado o contrário — "stable nos três pacotes com o mesmo digest".
Era falso, e ninguém o contestou porque era uma boa notícia.

CAUSA, medida nos runs, não deduzida:

    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, moveu 1.3.0 e 1.3, e NÃO
                                         moveu stable (a condição exige push)

`gh release create` empurra a tag E publica a release. Com `release: published`
ligado ao lado de `push: tags`, o mesmo commit foi construído duas vezes, com 5
minutos de diferença. Os labels confirmam: `revision` idêntico (9bd59e9) e
`version` idêntica nos seis, `created` separado por 5 min. Mesmo código, builds
distintos.

A segunda consequência é a grave, e é maior que a divergência de canal: 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" — e era o nosso
próprio workflow movendo o número. Editar e republicar uma release antiga
reconstruiria e moveria aquela versão de novo.

CONSERTO: o gatilho `release` sai. `push: tags: ["v*"]` já cobre o fluxo real.
A guarda nova reprova por COMPORTAMENTO — qualquer gatilho que possa republicar
sobre tag existente (`release`, `workflow_run`, `schedule`, `repository_dispatch`),
não só o que causou este caso — e reprova TAMBÉM se o gatilho de tag sumir, senão
ela passaria num workflow que não publica nada. Sabotada nos dois sentidos: cada
sabotagem derruba exatamente 1 dos 12.

E O CHECKLIST DE RELEASE ESTAVA NA ORDEM ERRADA. A conferência de `stable` era o
item 8 e o `gh release create` era o 10 — ou seja, a verificação rodava CINCO
MINUTOS ANTES do ato que a invalidava, e passava, verde e honesta. Verificação que
roda antes do passo que pode quebrá-la é verificação de nada. Foi para depois, nos
dois checklists (doutrina e DEPLOY-CHECKLIST), com o comando que compara digest.

De carona, a mesma classe de defeito em mais três lugares — perguntei "onde mais":

- a doutrina ainda dizia que `imagens-ok` "ainda não está" na branch protection.
  Está, desde 13/08. O parágrafo já tinha errado no sentido oposto antes; esta é a
  segunda vez que ele mente sobre o mesmo fato. Nota de pendência é dívida com data
  de vencimento e sem cobrador;
- CONTRIBUTING.md listava quatro obrigatórios e chamava o `imagens-ok` de
  não-bloqueante;
- CLAUDE.md e AGENTS.md mandavam procurar o job em `.github/workflows/imagens.yml`,
  arquivo que NÃO EXISTE — ele vive em `publish-image.yml`. Ponteiro morto numa
  linha que se lê como prova.

E o runbook de ativação, que já estava todo concluído, seguia se lendo como lista
de pendências: ganhou a tabela de estado com a prova de cada passo.

NÃO CONSERTADO, e é decisão sua: o `stable` publicado continua apontando para o
build de 19:53. Realinhá-lo exige `write:packages` (o token tem delete, não write)
e seria um `imagetools create`, sem rebuild. Impacto prático: nenhum — mesmo
commit, mesma versão. Impacto real: quem comparar digest para verificar o que
instalou encontra divergência legítima. Da próxima release em diante já sai certo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RKm2XcTcfgi1vdMcDYSRWa
2026-08-14 12:22:52 -03:00

179 lines
8.6 KiB
YAML

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:
build-and-push:
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` = a última RELEASE publicada. Existe porque `latest` aqui
# significa topo da `main` (código não lançado), e o nome engana:
# quem quer "a última versão estável" pedia `latest` e recebia edge.
# Trocar o significado de `latest` faria downgrade silencioso em quem
# já o consome, então o canal novo é o que ganha o nome certo.
# Três condições, e cada uma barra um caminho medido. `ref_type ==
# 'tag'` sozinho deixaria um dispatch numa branch mover o canal;
# `startsWith(…, 'v')` barra tag de teste (o registry já tem uma
# `quebrada-teste`, que nenhum push na main consegue produzir);
# e `event_name == 'push'` barra o pior caso — um dispatch numa
# release ANTIGA faria `stable` REGREDIR, e todo self-hoster no
# default do compose sofreria downgrade silencioso no próximo
# `up -d`, com app velho sobre banco já migrado.
type=raw,value=stable,enable=${{ github.event_name == 'push' && github.ref_type == 'tag' && startsWith(github.ref_name, 'v') }}
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).
# 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]
steps:
- name: Falhar se qualquer imagem não construiu
run: |
echo "resultado do build-and-push: ${{ needs.build-and-push.result }}"
[ "${{ needs.build-and-push.result }}" = "success" ]