Merge pull request #251 from melgarafael/fix/canal-stable-divergente

fix(ci): dois gatilhos para a mesma tag — `stable` e `1.3.0` são builds diferentes
This commit is contained in:
Rafael Melgaço
2026-08-14 15:23:29 -03:00
committed by GitHub
8 changed files with 115 additions and 23 deletions
+21 -2
View File
@@ -15,8 +15,27 @@ on:
push:
tags: ["v*"]
branches: ["main"]
release:
types: [published]
# 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
+1 -1
View File
@@ -71,7 +71,7 @@ Resend + Nuvemshop; é a P0 da doutrina de QA) — ou seja, `e2e` verde não pro
instalação fresca. `followup-journey`, `webhooks` e `capacidades-do-agente` estiveram fora e
**voltaram**: rodam hoje (`e2e.yml`, listas `SPECS_PARTE_1`/`SPECS_PARTE_2`).
`.github/workflows/imagens.yml`: `imagens-ok` = as três imagens Docker constroem. **Obrigatório
`.github/workflows/publish-image.yml`: `imagens-ok` = as três imagens Docker constroem. **Obrigatório
desde 2026-08-13.**
**Os cinco são checks obrigatórios** na branch protection da `main` — medido em 2026-08-14 @ `741c4ec8`:
+1 -1
View File
@@ -262,7 +262,7 @@ Checks **obrigatórios** na branch protection da `main` (verificado na configura
- **`invariants`** (`ci.yml`) — `pnpm test:db`: sobe `pgvector/pgvector:pg17`, aplica `supabase/baseline.sql` em modo install (`ON_ERROR_STOP=1`) e update (idempotência), e roda os testes de invariante, incluindo o de isolamento RLS entre 2 organizações.
- **`build-and-size`** (`perf.yml`) — `pnpm build` em Node 22.
- **`e2e`** (`e2e.yml`) — sobe Supabase local, aplica o `baseline.sql` e roda **45 das 46 specs** Playwright (medido em 2026-08-14 @ `741c4ec8`; **reconte antes de citar**, com `ls tests/e2e/*.spec.ts | wc -l` e `grep -oE '[a-z0-9-]+\.spec\.ts' .github/workflows/e2e.yml | sort -u | wc -l` — este número já apodreceu **três** vezes, e uma delas foi eu copiando o `echo` do próprio workflow em vez de contar os arquivos). A **única** de fora é `vps-fresh-onboarding` (precisa de WAHA + Redis + Resend + Nuvemshop) — e ela é a **P0** da doutrina de QA Visual, ou seja, `e2e` verde **não** prova a jornada de instalação fresca, que é o produto que se vende.
- **`imagens-ok`** (`imagens.yml`) — reprova quando qualquer uma das três imagens Docker não constrói. **É obrigatório desde 2026-08-13**; este arquivo dizia o contrário em outro parágrafo (ver a doutrina de packaging acima, já corrigida).
- **`imagens-ok`** (`publish-image.yml`) — reprova quando qualquer uma das três imagens Docker não constrói. **É obrigatório desde 2026-08-13**; este arquivo dizia o contrário em outro parágrafo (ver a doutrina de packaging acima, já corrigida).
Todos os **cinco** são **obrigatórios** — medido em 2026-08-14 na branch protection:
+3 -3
View File
@@ -77,10 +77,10 @@ Ao finalizar um epic:
- `pnpm test:e2e` (subset relevante) — **opcional se você contribui de fora**, ver abaixo
4. Abrir PR contra `main`. Description deve referenciar o epic e listar evidências (logs/screenshots dos testes).
5. CI deve passar antes de merge. Obrigatórios: `verify`, `invariants` (isolamento RLS),
`build-and-size` e `e2e`.
`build-and-size`, `e2e` e `imagens-ok`.
O job `imagens-ok` (constrói as três imagens que o self-hoster instala) roda em PR e
**ainda não bloqueia** — a ativação depende de um passo de administração do repositório.
O `imagens-ok` (em `.github/workflows/publish-image.yml`) constrói as três imagens que o
self-hoster instala, roda em PR e **bloqueia** desde 2026-08-13.
Verde no `e2e` **não** é "jornada provada": ele mesmo imprime, no resumo, quais specs não
cobriu — e a que fica de fora é justamente `vps-fresh-onboarding`, a instalação do zero.
+4 -1
View File
@@ -42,8 +42,11 @@ O procedimento completo está em [`doctrine/packaging.md`](doctrine/packaging.md
- [ ] `git tag vX.Y.Z && git push origin vX.Y.Z` a partir de um commit da `main`
- [ ] `gh run list --workflow=publish-image.yml --limit 3` → verde
- [ ] As **três** imagens existem na versão: `deskcommcrm`, `deskcomm-worker`, `deskcomm-scheduler`
- [ ] `stable` aponta para esta versão (mesmo digest de `X.Y.Z`)
- [ ] `gh release create vX.Y.Z` com as notas do CHANGELOG
- [ ] **Depois da release**, `stable` e `X.Y.Z` são o mesmo digest nas três imagens.
Nesta ordem, e não antes: na v1.3.0 a conferência rodou antes do
`gh release create`, passou, e o próprio `release` republicou a versão em
cima — verde às 19:53, divergente às 19:58.
**Provar (o item que exige VPS, e não é opcional)**
+39 -15
View File
@@ -104,22 +104,24 @@ OCI — no mínimo `source`, `revision`, `version`, `licenses` — e é constru
das três imagens não constrói. Ele existe porque a matriz gera um nome de check por imagem,
e exigir os três pelo nome faria uma quarta imagem, um dia, escapar do gate em silêncio.
> **Pendência de ativação — leia antes de citar esta linha como garantia.** `imagens-ok`
> **ainda não está** na branch protection. Medido em 2026-08-13:
> **Ativado.** `imagens-ok` **é** required check da `main`. Medido em 2026-08-14:
>
> ```console
> $ gh api repos/melgarafael/DeskcommCRM/branches/main/protection \
> --jq '.required_status_checks.contexts|join(", ")'
> verify, build-and-size, invariants, e2e
> verify, build-and-size, invariants, e2e, imagens-ok
> ```
>
> Enquanto isso valer, este invariante é **conselho, não gate**. A ativação é o último passo
> do merge desta doutrina, e não pode vir antes: um required check que não existe na base
> dos PRs já abertos bloqueia todos eles até que cada um rebase. O roteiro, com as
> verificações de cada passo, está em
> [`../runbooks/ativar-packaging.md`](../runbooks/ativar-packaging.md). Uma versão anterior deste
> parágrafo afirmava, no presente, que o check já era obrigatório — exatamente o defeito que
> esta doutrina existe para impedir, cometido dentro dela.
> Este parágrafo já disse as duas coisas erradas, em ordem: primeiro afirmou no presente
> que o check era obrigatório quando não era, depois — corrigido — afirmou que "ainda não
> está" e **continuou afirmando isso depois da ativação**, que aconteceu no mesmo dia. O
> segundo erro é o mais instrutivo: o texto foi escrito *sabendo* que a ativação era o
> passo seguinte, e ninguém volta para trocar um "ainda não" por um "já". **Nota de
> pendência é dívida com data de vencimento e sem cobrador.** Quem ler qualquer uma das
> duas versões mede contra a régua errada — reconfira na fonte, com o comando acima.
>
> O roteiro da ativação, com as verificações de cada passo, está em
> [`../runbooks/ativar-packaging.md`](../runbooks/ativar-packaging.md).
O gate importa porque já falhou: em 2026-08-12 um bump de `next` passou pelos quatro
obrigatórios e quebrou o build da imagem na `main`, porque o `next build` dentro do
@@ -332,24 +334,46 @@ do banco. É o passo que mais trava na estreia de uma imagem nova.
for i in deskcommcrm deskcomm-worker deskcomm-scheduler; do
echo "$i: $(ghcr_status $i X.Y.Z)"; done → 200 nas três
403 em alguma? Torne o pacote público ANTES de seguir
[ ] 8. `stable` aponta para esta versão (mesmo digest de X.Y.Z):
ghcr_status deskcommcrm stable → 200, e o digest bate
[ ] 9. A imagem reporta a versão certa:
[ ] 8. A imagem reporta a versão certa:
docker run --rm ghcr.io/melgarafael/deskcommcrm:X.Y.Z \
node -e 'console.log(process.env.APP_VERSION)' → X.Y.Z
[ ] 10. `gh release create vX.Y.Z` com as notas do CHANGELOG
[ ] 9. `gh release create vX.Y.Z` com as notas do CHANGELOG
[ ] 10. SÓ AGORA: `stable` e X.Y.Z são o MESMO digest, nas três imagens:
for i in deskcommcrm deskcomm-worker deskcomm-scheduler; do
for t in X.Y.Z stable; do
echo -n "$i:$t "; docker buildx imagetools inspect \
ghcr.io/melgarafael/$i:$t --format '{{.Manifest.Digest}}'; done; done
→ o par de cada imagem tem que bater
Não bateu? Alguma coisa republicou depois do push da tag. NÃO siga:
um canal apontando para build diferente da versão é o invariante 3
quebrado dentro de casa.
[ ] 11. Apagar tags de branch dos três pacotes — `docs-doutrina-packaging` e
qualquer outra que tenha nascido de um `workflow_dispatch` de ensaio.
Tag de branch é artefato de trabalho: se ficar, vira canal órfão que
alguém pina por engano achando que é release, e ela nunca mais se move.
O registry já carrega uma dessas (`quebrada-teste`) como lembrete.
> **Por que a checagem de `stable` é o item 10 e não o 8.** Ela já foi o 8, antes do
> `gh release create` — e nessa ordem ela não provava nada. Medido na v1.3.0: o
> `release: published` estava ligado no workflow, `gh release create` disparou um
> segundo build do mesmo commit, e esse build **moveu `1.3.0` e `1.3`** sem mover
> `stable`. A conferência do item 8 tinha passado, verde e honesta, cinco minutos
> antes do ato que a invalidou. **Verificação que roda antes do passo que pode
> quebrá-la é verificação de nada.** O gatilho foi removido (guarda em
> `tests/unit/packaging-artefato-do-cliente.test.ts`), e a conferência foi para
> depois — cinto e suspensório, porque o próximo jeito de republicar uma tag ainda
> não foi inventado.
EXIGE ESCOPO QUE O TOKEN PADRÃO DO `gh` NÃO TEM. Medido no corte da
1.3.0: com `gist, read:org, repo, workflow` a API devolve 403 tanto para
listar quanto para apagar versão de pacote. Antes de chegar aqui:
gh auth refresh -h github.com -s read:packages,delete:packages
Sem isso o item fica pendente e a tag de ensaio segue viva — foi o que
aconteceu na 1.3.0.
aconteceu na 1.3.0. (Resolvido em 2026-08-14: as três versions foram
apagadas e a tag responde 404 nos três pacotes. Apague a **version**, e
só depois de conferir que ela não carrega OUTRA tag junto — no GHCR se
apaga version, não tag, e uma version com `1.3.0` ao lado levaria a
release embora.)
[ ] 12. Ensaio de atualização numa instalação real (não fresca): update.sh a partir da
versão anterior, e o /api/v1/health responde X.Y.Z
```
+14
View File
@@ -1,5 +1,19 @@
# Runbook — ativar a doutrina de packaging (uma vez só)
> **CONCLUÍDO em 2026-08-14. Este runbook é histórico** — guardado porque descreve o
> procedimento e as armadilhas de cada passo, não porque haja algo a fazer.
>
> | Passo | Estado | Prova |
> |---|---|---|
> | 1–2. Pacotes públicos | feito | `docker pull` anônimo resolve as três |
> | 3. `imagens-ok` obrigatório | feito | `protection` → `verify, build-and-size, invariants, e2e, imagens-ok` |
> | 4. Primeira release completa | feito | v1.3.0; `1.3.0` e `stable` nos três pacotes |
> | 5. Tag de ensaio apagada | feito | `docs-doutrina-packaging` → 404 nos três |
>
> Reconfira na fonte antes de confiar nesta tabela — foi por confiar numa nota de estado
> que a doutrina passou um dia inteiro afirmando que o `imagens-ok` não bloqueava, depois
> de ele já bloquear.
Este documento existe porque a entrega da [doutrina de packaging](../doctrine/packaging.md)
tem três passos que **não podem estar dentro do PR**: dois dependem de administração do
repositório e um depende de as imagens existirem. Enquanto eles não forem dados, parte da
@@ -231,6 +231,38 @@ describe("packaging — o artefato que o cliente instala", () => {
);
expect(wf, "publish-image.yml não cria o canal 'stable'").toContain("value=stable");
});
it("nenhum gatilho reconstrói uma tag já publicada", () => {
// Medido na v1.3.0: com `release: types: [published]` ligado ao lado de
// `push: tags`, `gh release create` disparou DOIS runs para o mesmo commit
// (19:53 push, 19:58 release). O segundo moveu `1.3.0` e `1.3` para um build
// novo e deixou `stable` no antigo — digests divergentes para o mesmo
// `revision`, e uma tag de VERSÃO movida depois de publicada, que é
// exatamente o que o invariante 3 da doutrina proíbe.
//
// Este teste guarda o COMPORTAMENTO (o que dispara um build que publica),
// não a ausência de uma string: qualquer gatilho novo que re-publique sobre
// uma tag existente reprova aqui, não só o `release`.
const wf = fs.readFileSync(path.join(RAIZ, ".github/workflows/publish-image.yml"), "utf8");
const gatilhos = wf.split(/^jobs:/m)[0] ?? "";
const semComentarios = gatilhos
.split("\n")
.filter((l) => !/^\s*#/.test(l))
.join("\n");
for (const proibido of ["release:", "workflow_run:", "schedule:", "repository_dispatch:"]) {
expect(
semComentarios,
`publish-image.yml reagiria a '${proibido}' — esse gatilho pode reconstruir e MOVER uma tag de versão já publicada`,
).not.toContain(proibido);
}
// E o gatilho que precisa existir continua existindo — sem esta linha o
// teste passaria num workflow que não publica coisa nenhuma.
expect(semComentarios, "publish-image.yml deixou de reagir a push de tag").toMatch(
/tags:\s*\["?v\*/,
);
});
});
describe("packaging — a versão que roda é observável de fora", () => {