mirror of
https://github.com/melgarafael/DeskcommCRM.git
synced 2026-10-02 01:28:34 +08:00
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:
co-authored by
Claude Opus 5
parent
3d41250859
commit
8d03841d96
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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);
|
||||
|
||||
@@ -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\]/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user