feat(release): a tag nasce no CI, de commit contido na main, e prova que as imagens sairam

Fecha o ciclo que os dois commits anteriores prepararam. Dois atos, e em nenhum
deles alguem digita `git tag`:

1. voce dispara "Run workflow" -> le `.changes/`, calcula o numero, monta a secao
   e abre um PR de release em portugues dizendo o que vai sair;
2. voce faz merge -> a tag `vX.Y.Z` nasce, e e ela que faz o `publish-image.yml`
   publicar as tres imagens que a VPS puxa.

**Por que um GitHub App e nao o GITHUB_TOKEN.** Documentado pelo proprio GitHub:
evento disparado com o GITHUB_TOKEN nao cria novo workflow run. Se a tag nascesse
dele, `publish-image.yml` JAMAIS rodaria — a tag existiria, nenhum erro apareceria
em lugar nenhum, e nenhuma VPS receberia a atualizacao. E uma falha silenciosa que
so seria descoberta por um cliente tentando atualizar. As alternativas caem todas:
`repository_dispatch` e `workflow_run` sao proibidos por
`packaging-artefato-do-cliente.test.ts:253`; `workflow_dispatch` encadeado nao move
o canal `stable` (a condicao `event_name == 'push'` existe para barrar dispatch); e
PAT expira em silencio meses depois, na mao de quem nao e dev.

Efeito colateral bom: com o App, nenhum job do `release.yml` pede escopo de
escrita ao GITHUB_TOKEN — `permissions: contents: read` nos dois, e
`workflows-tem-permissions.test.ts` segue sem entrada nova em
ESCRITA_JUSTIFICADA.

**E o workflow nao confia: ele PROVA a consequencia.** Depois de empurrar a tag,
`cortar-tag` espera e confere as tres imagens no GHCR com a mesma sonda do
`docs/runbooks/ativar-packaging.md` — token anonimo de pull, de proposito: o que
interessa provar e que a imagem esta alcancavel para QUEM INSTALA, e quem instala
nao tem credencial nossa. Sonda validada aqui com controle positivo e negativo:
1.6.0 -> 200 e 9.9.9 -> 404 nas tres imagens.

**A trava de procedencia.** `on: push: tags: ["v*"]` reage a tag de QUALQUER ramo,
e nenhuma das tres condicoes do canal `stable` olha de onde ela veio — nem o
`type=semver`, que publica `X.Y.Z` do mesmo jeito. A branch protection nao alcanca
isso (vale para a branch `main`, nao para refs de tag). O job novo
`a-tag-veio-da-main` pergunta a API se o commit esta contido na main e reprova
ANTES de qualquer publicacao. Ele nao tem `if:` de job de proposito: job pulado
deixaria `build-and-push` pulado, e `imagens-ok` (que roda com `always()`) le
`skipped` como reprovacao.

Isso nao e hipotetico: este repo ja teve na arvore uma tag `v1.4.0` de um FORK,
hoje renomeada para `jmpo/v1.4.0` para nao colidir com a numeracao daqui — e o
`agent.sh` oferece a MAIOR tag `v*` a toda VPS sem olhar origem nenhuma.

Sabotagens previstas e medidas: `build-and-push` sem depender da trava -> 1 falha;
a trava aceitando `ahead` -> reprova; App trocado por `contents: write` -> reprova.
A primeira rodada destas sabotagens me custou o proprio trabalho: `git checkout`
reverteu o `publish-image.yml` para um HEAD que ainda nao tinha a trava, e o
`release.yml` era untracked, entao o checkout nao o restaurou. A licao ja estava
escrita — commitar antes de sabotar — e e por isso que este commit vem ANTES da
proxima rodada.

PENDENTE, e a ordem importa: os secrets RELEASE_APP_ID e RELEASE_APP_PRIVATE_KEY
ainda nao existem. Ate eles existirem, `abrir-pr-de-release` falha no primeiro
step (nao publica nada errado — so nao roda). O `pre-push` barrando tag manual e o
ruleset de tags entram DEPOIS disto, nunca antes: sozinhos, barrariam o caminho
manual sem o automatico existir.

Evidencia: `pnpm vitest run tests/unit/tag-so-nasce-da-main.test.ts
tests/unit/workflows-tem-permissions.test.ts
tests/unit/packaging-artefato-do-cliente.test.ts` -> 24 passed (24).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DAtZo5GWZn8ckdmsoUVKts
This commit is contained in:
Rafael Melgaço
2026-08-27 09:55:13 -03:00
co-authored by Claude Opus 5
parent 3d41250859
commit 8d03841d96
4 changed files with 318 additions and 1 deletions
+46
View File
@@ -50,7 +50,52 @@ 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
@@ -165,6 +210,7 @@ jobs:
# 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
+177
View File
@@ -0,0 +1,177 @@
name: release
# Os dois atos do ciclo, e nenhum deles cria tag na máquina de ninguém.
#
# 1. VOCÊ dispara "Run workflow" → ele lê `.changes/`, calcula o número, monta
# a seção do CHANGELOG e abre um PR de release em português dizendo o que
# vai sair. Nada é publicado ainda.
# 2. VOCÊ faz merge desse PR → ele cria a tag `vX.Y.Z`, e é a tag que faz o
# `publish-image.yml` publicar as três imagens que a VPS puxa.
#
# Quem escreve (branch, PR, tag) é o token de um GitHub App, NUNCA o
# GITHUB_TOKEN. A razão é uma armadilha documentada pelo próprio GitHub: evento
# disparado com o GITHUB_TOKEN não cria novo workflow run. Se a tag nascesse
# dele, o `publish-image.yml` jamais rodaria — a tag existiria, ninguém veria
# erro, e NENHUMA VPS receberia a atualização. Falha silenciosa, descoberta só
# quando um cliente tentasse atualizar.
#
# Por isso o ato 2 não confia: ele espera a publicação e CONFERE as três imagens
# no registro, falhando alto se não aparecerem.
on:
workflow_dispatch: {}
push:
branches: [main]
# O GITHUB_TOKEN aqui só clona e lê. Todo escopo de escrita vem do App, o que
# mantém `tests/unit/workflows-tem-permissions.test.ts` sem entrada nova.
permissions:
contents: read
jobs:
abrir-pr-de-release:
if: github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/create-github-app-token@v2
id: token
with:
app-id: ${{ secrets.RELEASE_APP_ID }}
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
- uses: actions/checkout@v7
with:
# `fetch-depth: 0` porque o corte compara com as tags, e o checkout
# raso padrão não traz nenhuma.
fetch-depth: 0
token: ${{ steps.token.outputs.token }}
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Calcular o número e montar a seção
id: corte
run: |
set -euo pipefail
pnpm exec tsx scripts/cortar-release.ts --escrever | tee /tmp/corte.txt
versao=$(pnpm exec tsx scripts/cortar-release.ts --versao-do-changelog)
echo "versao=${versao}" >> "$GITHUB_OUTPUT"
- name: Abrir o PR
env:
GH_TOKEN: ${{ steps.token.outputs.token }}
VERSAO: ${{ steps.corte.outputs.versao }}
run: |
set -euo pipefail
git config user.name 'deskcomm-release[bot]'
git config user.email '${{ steps.token.outputs.app-slug }}[bot]@users.noreply.github.com'
git checkout -b "release/${VERSAO}"
git add CHANGELOG.md .changes
git commit -m "release(${VERSAO}): a versão montada a partir dos fragmentos declarados"
git push origin "release/${VERSAO}"
{
echo "Esta versão sai como **${VERSAO}**, e o número foi CALCULADO a partir do que cada PR declarou — ninguém digitou."
echo
echo '```'
cat /tmp/corte.txt
echo '```'
echo
echo "Ao fazer merge deste PR, a tag \`v${VERSAO}\` é criada automaticamente e as três imagens são publicadas."
echo "Se algo aqui estiver errado, **feche o PR** em vez de editar o número: corrija o fragmento e dispare o workflow de novo."
} > /tmp/corpo.md
gh pr create --base main --head "release/${VERSAO}" \
--title "Release ${VERSAO}" --body-file /tmp/corpo.md
cortar-tag:
if: github.event_name == 'push'
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/create-github-app-token@v2
id: token
with:
app-id: ${{ secrets.RELEASE_APP_ID }}
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
- uses: actions/checkout@v7
with:
fetch-depth: 0
token: ${{ steps.token.outputs.token }}
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: A main anuncia uma versão que ainda não tem tag?
id: pendente
run: |
set -euo pipefail
versao=$(pnpm exec tsx scripts/cortar-release.ts --versao-do-changelog)
if git rev-parse -q --verify "refs/tags/v${versao}" >/dev/null; then
echo "v${versao} já existe — nada a cortar (este push não era um merge de release)"
echo "cortar=nao" >> "$GITHUB_OUTPUT"
else
echo "v${versao} ainda não existe — esta main traz uma versão nova"
echo "cortar=sim" >> "$GITHUB_OUTPUT"
echo "versao=${versao}" >> "$GITHUB_OUTPUT"
fi
- name: Criar e empurrar a tag
if: steps.pendente.outputs.cortar == 'sim'
env:
VERSAO: ${{ steps.pendente.outputs.versao }}
run: |
set -euo pipefail
git config user.name 'deskcomm-release[bot]'
git config user.email '${{ steps.token.outputs.app-slug }}[bot]@users.noreply.github.com'
# Tag ANOTADA: guarda autor e data, e é o que `git describe
# --exact-match` do agent.sh espera encontrar na VPS.
git tag -a "v${VERSAO}" -m "v${VERSAO}"
git push origin "v${VERSAO}"
# A falha desta cadeia é silenciosa por natureza — a tag existe e ninguém
# vê erro —, então o workflow prova a CONSEQUÊNCIA em vez de supô-la.
- name: As três imagens existem nesta versão?
if: steps.pendente.outputs.cortar == 'sim'
env:
VERSAO: ${{ steps.pendente.outputs.versao }}
run: |
set -euo pipefail
# A mesma sonda do runbook (docs/runbooks/ativar-packaging.md): token
# anônimo de pull do próprio registro. Não usa o GITHUB_TOKEN de
# propósito — o que interessa provar é que a imagem está alcançável
# para QUEM INSTALA, e quem instala não tem credencial nossa.
ghcr_status() {
local t
t=$(curl -s "https://ghcr.io/token?scope=repository:${GITHUB_REPOSITORY_OWNER}/$1:pull&service=ghcr.io" \
| sed -n 's/.*"token":"\([^"]*\)".*/\1/p')
curl -s -o /dev/null -w '%{http_code}' -H "Authorization: Bearer $t" \
-H 'Accept: application/vnd.oci.image.index.v1+json' \
"https://ghcr.io/v2/${GITHUB_REPOSITORY_OWNER}/$1/manifests/$2"
}
echo "esperando a publicação de v${VERSAO}…"
for _ in $(seq 1 60); do
faltando=""
for img in deskcommcrm deskcomm-worker deskcomm-scheduler; do
[ "$(ghcr_status "$img" "${VERSAO}")" = "200" ] || faltando="${faltando} ${img}"
done
if [ -z "$faltando" ]; then
echo "as três imagens respondem em ${VERSAO}"
exit 0
fi
sleep 30
done
echo "::error::A tag v${VERSAO} foi criada, mas as imagens não apareceram:${faltando}"
echo "::error::O parque instalado NÃO consegue atualizar para esta versão."
echo "::error::Confira o run de publish-image.yml para a tag v${VERSAO}."
exit 1
+11 -1
View File
@@ -94,12 +94,22 @@ function hoje(): string {
function main(argv: readonly string[]): number {
const escrever = argv.includes("--escrever");
const desconhecido = argv.find((a) => a !== "--escrever");
const soAVersao = argv.includes("--versao-do-changelog");
const conhecidos = new Set(["--escrever", "--versao-do-changelog"]);
const desconhecido = argv.find((a) => !conhecidos.has(a));
if (desconhecido) {
process.stderr.write(`argumento desconhecido: ${desconhecido}\n`);
return 2;
}
// O workflow de release usa isto para saber se o merge que acabou de entrar
// na main trouxe uma versão nova. Imprime só o número, sem mais nada, para
// caber num `$(...)`.
if (soAVersao) {
process.stdout.write(`${versaoBase(fs.readFileSync(CHANGELOG, "utf8"))}\n`);
return 0;
}
const fragmentos = lerFragmentos(DIR_FRAGMENTOS);
const changelog = fs.readFileSync(CHANGELOG, "utf8");
const base = versaoBase(changelog);
+84
View File
@@ -0,0 +1,84 @@
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
/**
* A tag `vX.Y.Z` é o gatilho de atualização do parque instalado inteiro:
* `hostgator-setup-kit/agent.sh` oferece a MAIOR tag `v*` a toda VPS, e o
* `update.sh` puxa a imagem por aquele número. Este arquivo vigia as duas
* propriedades que impedem que ela vire uma porta aberta.
*/
const RAIZ = process.cwd();
const publish = fs.readFileSync(path.join(RAIZ, ".github/workflows/publish-image.yml"), "utf8");
const release = fs.readFileSync(path.join(RAIZ, ".github/workflows/release.yml"), "utf8");
/** As linhas de um job, até o próximo job na mesma indentação. */
function job(yml: string, nome: string): string {
const linhas = yml.split("\n");
const i = linhas.findIndex((l) => l === ` ${nome}:`);
if (i === -1) return "";
const fim = linhas.findIndex((l, n) => n > i && /^ {2}[a-z-]+:$/.test(l));
return linhas.slice(i, fim === -1 ? undefined : fim).join("\n");
}
describe("nenhuma tag publica sem estar contida na main", () => {
it("o job da trava existe", () => {
expect(job(publish, "a-tag-veio-da-main"), "a trava de procedência sumiu de publish-image.yml").not.toBe("");
});
it.each(["build-and-push", "imagem-do-app-sobe"])(
"%s depende da trava — senão publica antes de ela responder",
(nome) => {
expect(job(publish, nome)).toMatch(/needs:\s*\[[^\]]*a-tag-veio-da-main/);
},
);
it("a trava exige `identical` ou `behind` no compare com a main", () => {
const t = job(publish, "a-tag-veio-da-main");
expect(t).toContain("compare/main...");
// `ahead` e `diverged` são commit que a main não tem. Aceitar qualquer um
// dos dois devolveria a porta que este job fecha.
expect(t).toMatch(/identical\|behind/);
expect(t).not.toMatch(/\bahead\|/);
});
it("a trava NÃO tem `if:` de job — pulada, ela vira `skipped` e o imagens-ok lê isso como reprovação", () => {
const t = job(publish, "a-tag-veio-da-main");
// `if:` de STEP é permitido; o que não pode é o `if:` na altura do job
// (quatro espaços), que faz o GitHub pular o job inteiro.
expect(t.split("\n").filter((l) => /^ {4}if:/.test(l))).toEqual([]);
});
});
describe("a tag nasce no CI, e nunca do GITHUB_TOKEN", () => {
it("o release usa o token do GitHub App para escrever", () => {
// Evento disparado com o GITHUB_TOKEN não cria novo workflow run (doc do
// GitHub). Se a tag nascesse dele, `publish-image.yml` nunca rodaria: a tag
// existiria, nenhum erro apareceria, e NENHUMA VPS receberia a atualização.
expect(release).toContain("actions/create-github-app-token");
expect(release).toContain("secrets.RELEASE_APP_ID");
expect(release).toContain("secrets.RELEASE_APP_PRIVATE_KEY");
});
it("nenhum job do release pede escopo de escrita ao GITHUB_TOKEN", () => {
const escritas = release
.split("\n")
.filter((l) => /^\s+(contents|pull-requests|packages):\s*write\s*$/.test(l));
expect(escritas, "escrita pelo GITHUB_TOKEN: quem escreve aqui tem que ser o App").toEqual([]);
});
it("o corte da tag prova que as imagens saíram — a falha aqui é silenciosa por natureza", () => {
const t = job(release, "cortar-tag");
expect(t).toContain("ghcr_status");
for (const img of ["deskcommcrm", "deskcomm-worker", "deskcomm-scheduler"]) {
expect(t, `a conferência não cobre ${img}`).toContain(img);
}
expect(t).toMatch(/::error::/);
});
it("a tag só é criada em push na main, nunca num dispatch de branch qualquer", () => {
expect(job(release, "cortar-tag")).toMatch(/if:\s*github\.event_name == 'push'/);
expect(release).toMatch(/push:\s*\n\s*branches:\s*\[main\]/);
});
});