fix(ia): a camada plataforma não impõe "assistente virtual" à apresentação do agente #1419
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" ] |