- deploy.yml: trigger push em main + preflight guard (pula se há run queued/in_progress; waiting não conta) e job hosted requeue após deploy-staging (fora do grupo deploy-homeserver) - scripts/lib/deploy-trigger.mjs (decidePreflight/decideRequeue) + CLIs deploy-preflight/deploy-requeue; github-api ganha listWorkflowRuns/getBranchHead - pins: ciSkipInvariants reescrito (push + guard + dispatch + requeue fora do grupo), deployTrigger/githubApi novos; deployScript intocado - docs realinhados (AGENTS*, AGENT-OPS, README, runbook, rules/skills) + changelog Closes #980
24 KiB
Impl: OPS104 — Disparo automático do deploy pós-merge (staging automático, produção com review)
Status: aprovado (gate humano 2026-09-14) Atualizado em: 2026-09-14 Issue: #980 Intenção: docs/plans/ops104-disparo-automatico-do-deploy.md Appetite restante: ~1 dia eng (herdado; cortes explícitos em "Rabbit holes / Não escopo")
Leitura da intenção
- Outcome: todo merge em
mainsem deploy ativo inicia sozinho odeploy.ymlno head;verifyfull →deploy-stagingautomático; ao terminar o staging, semainandou, um run novo nasce no head novo; produção continua somente com aprovação humana do environmentproduction. Dispatch manual segue como escape. - O que NÃO negociar: nunca auto-aprovar/publicar produção; nunca atalhar o
verifyfull; nunca um workflow/script gêmeo (o dono é odeploy.yml); nunca tocarci-pr.yml, o jobverify, os guards de banco local, secrets ou oscripts/deploy-homeserver.sh(pinado pordeployScript.unit.spec.ts); nunca reintroduzir o stale-run guard (OPS102);concurrency: deploy-homeserverintocado (cancel-in-progress: false,queue: max) e só nos jobs de deploy;waiting(aprovação pendente) não conta como run ativo no guard; sem retry/auto-heal de vermelho; sem cancelar runs antigos. - O que reavaliar (hipóteses da intenção):
tests/unit/ciSkipInvariants.unit.spec.tsl.113-114 (lê YAML cru): confirmado —toContain('workflow_dispatch:')enot.toMatch(/^\s*push:/m)quebram ao adicionarpush; o teste l.124-150 é additive-safe (jobs/chaves novas passam; o literalneeds: [deploy-staging]não pode mudar). O pin do manual-only será reescrito nesta entrega.deployScript.unit.spec.tsnão muda (o script é congelado;git ls-remotesegue banido dentro dele).- Achado novo de engenharia (não antecipado na intenção): no GitHub, um job aguardando aprovação de environment segura o grupo
concurrency(comportamento documentado na community discussion #67607 — a avaliação de concurrency acontece antes do approval). Consequência: enquanto uma aprovação de produção espera, odeploy-stagingde um run novo ficapendingatrás dela. O aceite de produto continua satisfeito (o run novo nasce;waitingnão bloqueia o trigger 1; nada publica sem reviewer), mas "staging atualiza sozinho" pausa durante uma aprovação pendente. Isso vira risco documentado (R2), não redesign — o grupo compartilhado é decisão da OPS103/intenção. - Duplicata do trigger 2: o run de
pushque o preflight skipou terminacompleted(jobs skipados) — indistinguível de "já deployado". Dedupe por "existe run no head" mascararia um SHA não publicado; então não há dedupe (D4).
Abordagem recomendada
flowchart LR
P[merge/push em main] --> PF{"preflight<br/>existe run queued/in_progress<br/>(excluindo o próprio)?"}
M[workflow_dispatch] --> V
PF -- "sim" --> N["run nasce e para<br/>(verify/deploys skipados)"]
PF -- "não" --> V["verify full (hosted)"]
V --> S["deploy-staging<br/>(self-hosted, grupo deploy-homeserver)"]
S --> R{"head de main == SHA do run?"}
R -- "não" --> D["requeue (hosted):<br/>dispatch deploy.yml ref main"]
D -. "novo run" .-> V
R -- "sim" --> W["deploy-production<br/>aguarda reviewer"]
W -- "approve" --> PR["deploy-production<br/>(self-hosted)"]
Opções consideradas: A) trigger 1 = push no próprio deploy.yml + job de preflight que pula o run quando já há deploy ativo, e trigger 2 = job hosted de requeue após deploy-staging (recomendada) | B) workflow gêmeo "dispatcher" separado do deploy.yml | C) só concurrency no deploy.yml (sem guard nem requeue) | D) trigger por pull_request: closed em vez de push.
Recomendação: A — um dono só (sem drift), o pin unitário do manual-only passa a testar o guard, o dispatch manual continua no mesmo arquivo, e o requeue hosted não entra no grupo de deploy (não pode ser bloqueado pela aprovação pendente). É exatamente o decidido no gate de produto.
Rejeitadas: B porque twin cria drift e um segundo lugar onde o "run ativo" escapa do guard (anti-goal explícito); C porque concurrency enfileira, não pula — todo push rodaria verify full de novo e SHAs intermediários deployariam em ordem (contra "nada nasce"); D porque o merge via GITHUB_TOKEN (anti-recursão) não cria run de pull_request closed (mesma classe do OPS71-FLIP) e o contrato push é o decidido.
Decisões de engenharia (caras de reverter)
D1 — Como detectar run ativo (guard do trigger 1)
- Opções: A) REST
GET /repos/{owner}/{repo}/actions/workflows/deploy.yml/runsvia helper node emscripts/lib/github-api.mjs; B)gh run listinline no YAML; C)concurrencypuro (queue: max) sem guard; D) checar check-runs/status do commit. - Recomendação: A — dois
GETcomstatus=queuedestatus=in_progress,branch=main,per_page=100; filtrarun.id != github.run_ide decide. Usa o client existente (retry OPS67 para GET/5xx), testável comfetchImpl, sem dependência de CLI. - Rejeitadas: B porque parseia texto de
gh(o repo tiroughdos scripts na OPS50), é frágil e não tem seam de teste; C porque não pula o run — só enfileira (o aceite é "nada nasce", e verify full a cada push é o que se quer evitar); D porque runqueuednão tem check-run no commit e um jobwaitingde aprovação (que não conta) é indistinguível dein_progresspor check-run.
D2 — Como detectar o head novo de main (trigger 2)
- Opções: A) REST
GET /repos/{owner}/{repo}/git/ref/heads/mainviagetBranchHead; B)git ls-remote https://github.com/fsolla/teqo.git refs/heads/main; C)git fetch+git rev-parse origin/main. - Recomendação: A — uma chamada REST no mesmo client, testável, sem checkout extra; o job hosted (ubuntu-latest) nem depende de
git. - Rejeitadas: B porque, embora banido apenas dentro do
deploy-homeserver.sh(pin do OPS102), repetir o padrão do stale-run guard no YAML confunde leitura/runbook e não tem teste unitário sem subprocess; C porque exige checkout+fetch pesado e adiciona estado local para uma comparação de SHA.
D3 — Onde mora a lógica reutilizável
- Opções: A) API em
scripts/lib/github-api.mjs(listWorkflowRuns,getBranchHead) + decisões puras emscripts/lib/deploy-trigger.mjs(decidePreflight,decideRequeue) + dois CLIs finos (scripts/deploy-preflight.mjs,scripts/deploy-requeue.mjs); B) tudo inline no YAML comcurl+jq; C) um script monolítico novo dono dos dois lados. - Recomendação: A — segue o padrão já estabelecido (
scripts/lib/github-pr-flow.mjsexportadecideAutomergeActione o CLIscripts/github-pr-automerge.mjssó faz o wiring; o clientgithub-api.mjsé o módulo profundo de HTTP). Decisões puras testáveis sem fetch; CLIs sem lógica. - Rejeitadas: B porque YAML não é testável, duplicaria a lógica entre os dois jobs e
jqvira contrato de string; C porque mistura HTTP + decisão num arquivo sem seam (testar exigiria mock global) e o entry do knip executaria side effects.
D4 — Dedupe do trigger 2 (run novo vs run de push skipado)
- Opções: A) aceitar a duplicata; B) antes de dispatchar, checar se já existe run no head novo e pular; C) cancelar runs antigos/duplicados.
- Recomendação: A — a duplicata é inerte (o run de push skipado não executou verify/deploy) e a janela de corrida é de segundos; o dispatch é idempotente (
already deployedverde no script para o mesmo SHA) e converge (o run novo compara head==SHA no fim e para). - Rejeitadas: B porque um run de push skipado fica
completed(jobs skipados) — "existe run no head" não distingue "run inerte" de "SHA já deployado", e o dedupe mascararia um SHA não publicado (violaria "staging não fica velho"); C porque é automação destrutiva sem decisão de produto (cancelar run de aprovação é ato humano).
D5 — Job de preflight sempre roda (com guard push-only dentro)
- Opções: A) job
preflightroda nos dois eventos; o step do guard temif: github.event_name == 'push'e o output do job cai em'true'por default (${{ steps.guard.outputs.should_deploy || 'true' }});verifyusaneeds: [preflight]+if: needs.preflight.outputs.should_deploy == 'true'. B) jobpreflightcomif: github.event_name == 'push'e consumidores comalways(). - Recomendação: A — o
success()implícito que o GitHub injeta em jobs comneedsfaria o dispatch pular overifyse o preflight fosse skipado (footgun conhecido); com A, dispatch e push compartilham um fluxo único e legível. - Rejeitadas: B por depender de
always()em cada consumidor para não quebrar o dispatch — frágil e fácil de regredir sem teste.
D6 — Semântica de falha da API
- Opções: A) preflight fail-open (não conseguiu listar → prossegue, com warning e output
true) e requeue fail-red (não conseguiu comparar/dispatchar → exit 1); B) ambos fail-closed/skip; C) ambos fail-open. - Recomendação: A — o preflight é dedupe, não gate de segurança: um blip de API não pode desligar o deploy automático em silêncio (é a dor que o OPS104 resolve), e o pior caso é um
verifyredundante. O requeue é a rede anti-estagnação: falhou, o job fica vermelho (política "nunca mentir", OPS61) e o operador re-dispatcha. - Rejeitadas: B porque reintroduz staleness silenciosa; C porque requeue silencioso deixa
mainvelho sem sinal.
D7 — Requeue roda em qualquer evento (push e dispatch)
- Recomendação: o job de requeue roda sempre que
deploy-stagingtermina verde (push do trigger 1 ou dispatch manual, refmain). O pedido é "sempre que o staging terminar e houver head novo". - Rejeitadas: restringir a
github.event_name == 'push'porque um dispatch de SHA antigo (rollback/incidente) ficaria sem convergência; a consequência (um dispatch de rollback gera também um run novo no head) é inofensiva — produção continua atrás de reviewer e o run novo só propõe o head.
Componentes / mudanças
.github/workflows/deploy.ymlname: Deploy(realinha com o novo gatilho; hojeDeploy (manual)) e header reescrito (o comentário atual diz "workflow_dispatch only").on: push: branches: [main]somado aworkflow_dispatch:(mantido).- Novo job
preflight(hosted,timeout-minutes: 5,permissions: { contents: read, actions: read },GITHUB_TOKEN: ${{ github.token }}, checkout), comoutputs.should_deploy: ${{ steps.guard.outputs.should_deploy || 'true' }}e o step do guard:if: github.event_name == 'push'→node scripts/deploy-preflight.mjs. verify: ganhaneeds: [preflight]+if: needs.preflight.outputs.should_deploy == 'true'(resto intocado).- Novo job
requeue(hosted,timeout-minutes: 5,needs: [deploy-staging],if: needs.deploy-staging.result == 'success',permissions: { contents: read, actions: write },GITHUB_TOKEN: ${{ github.token }}, checkout) →node scripts/deploy-requeue.mjs; fora do grupodeploy-homeservere não precisa dedeploy-production(um job aguardando approval não pode bloquear o requeue — decidido no gate). - Nada muda em
verify(passos),deploy-staging/deploy-production(needs[verify]/[deploy-staging], environments, concurrency) nem noscripts/deploy-homeserver.sh.
scripts/lib/github-api.mjs(estender o módulo dono):listWorkflowRuns(workflowFile, { status, branch, limit = 100 })(normaliza{ id, status, headSha, headBranch, event, createdAt, htmlUrl }) egetBranchHead(branch = 'main')→{ ref, sha }(RESTgit/ref/heads/{branch}). Reusarequest(retry OPS67). Sem helper de cancelamento/listagem paginada — escopo estrito.scripts/lib/deploy-trigger.mjs(novo, puro, espelhandogithub-pr-flow.mjs):ACTIVE_RUN_STATUSES = {queued, in_progress};decidePreflight({ eventName, currentRunId, runs })→{ shouldDeploy, reason, activeRunIds }(dispatch → true; excluicurrentRunId; só queued/in_progress de outros contam;waiting/requested/completednão);decideRequeue({ runSha, headSha })→{ dispatch, reason }(SHA ausente → erro no CLI; igual → não; diferente → sim).scripts/deploy-preflight.mjs(novo CLI): lêGITHUB_EVENT_NAME,GITHUB_RUN_ID,GITHUB_REPOSITORY,GITHUB_TOKEN; doislistWorkflowRuns('deploy.yml', { status, branch: 'main' }); chamadecidePreflight; escreveshould_deploy=<bool>em$GITHUB_OUTPUT; em erro de API → warning +true(D6).scripts/deploy-requeue.mjs(novo CLI): lêGITHUB_SHAeGITHUB_TOKEN;getBranchHead('main');decideRequeue; sedispatch→api.workflowDispatch('deploy.yml', { ref: 'main' }); erro → exit 1 (D6).tests/unit/deployTrigger.unit.spec.ts(novo): cobre D1/D4/D7 — dispatch nunca bloqueia; queued/in_progress de outro run pulam;waitingecompletednão; o run atual é excluído;decideRequeuecompara SHAs.tests/unit/githubApi.unit.spec.ts: casos paralistWorkflowRuns(URL/params/normalização) egetBranchHead(URL/normalização/404→null).tests/unit/ciSkipInvariants.unit.spec.ts: reescrever o pin l.111-122 ("manual-only") para "push emmain+ guard de run ativo +workflow_dispatchmantido"; manter l.124-150 (chain OPS103) e o pin doci-classify-production.tests/unit/deployScript.unit.spec.ts: não muda (invariante de congelamento do script).- Migration: não (sem schema).
- Access / Consent: N/A (sem PII, sem collection).
- UI: Impeccable A / N/A — sem superfície de UI; nenhum shell a reusar.
Dados → forma (se aplicável)
N/A — sem superfície de dados para usuário. A "evidência" é operacional: lista de runs, logs dos jobs preflight/requeue (should_deploy=…, main avançou → dispatch) e o approval pendente do environment production.
Fases verificáveis
- Tracer lógico (unit-first, sem YAML) —
scripts/lib/deploy-trigger.mjs+listWorkflowRuns/getBranchHeademgithub-api.mjs+tests/unit/deployTrigger.unit.spec.ts+ casos novos emgithubApi.unit.spec.ts. Gate:pnpm test:unitverde. ~2–3h. - Trigger 1 (push + preflight) —
scripts/deploy-preflight.mjs; YAML:on.push.branches[main], jobpreflight(com|| 'true'no output e guard push-only),verifycomneeds/if; reescrever o pin dociSkipInvariants(com a mesma filtragem de comentários do teste do chain, para prosa do header nunca satisfazer o pin). Gate:pnpm test:unitverde. ~2h. - Trigger 2 (requeue) —
scripts/deploy-requeue.mjs+ jobrequeue(needs staging,actions: write, fora do grupo). Gate:pnpm test:unitverde. ~1–2h. - Docs + changelog — checklist da seção "Docs a realinhar" +
docs/changelog/2026-09-14-ops104.md(uma entrada, additions-only). ~1,5h. - Gates e entrega —
pnpm gate:fast(lint+typecheck+unit),pnpm test:unit;pnpm push; PR ready (auto-merge); validação pós-merge (só existe no caminhopush, pós-merge): (a) o merge deste PR dogfooda o trigger 1 — conferir runDeploynasceu sozinho,preflightlogoushould_deploy=true,verifyverde, staging publicado, produção aguardando; (b) mergear um follow-up trivial durante overify/staging → conferir que o segundo run ou skipa no preflight ou orequeuedo run ativo dispara o run novo no head; (c) confirmar que produção só sai com approve e quedeploy-homeserver.sh/deployScriptseguem intocados. ~1h + observação.
Rabbit holes / Não escopo (engenharia)
- Auto-approve de
production, auto-retry deverifyvermelho, cancelamento automático de runs antigos (anti-goals do produto). - Workflow gêmeo/dispatcher separado; mover
verifypara self-hosted; mexer emci-pr.yml, nos guards de banco, em secrets ou na proteção de branch. - Lock por ambiente no
deploy-homeserver.shou grupos de concurrency separados staging/produção — o lock/grupo compartilhado é decisão da OPS103 (workspace/compose/registry são um só); tratado como risco R2 com gatilho de revisitação. - Refinar "ativo" por fase do run (listar jobs do run para saber se está em verify/staging/produção) — complexidade sem aceite; o guard por status do run é o decidido.
- Dedupe/cancelamento do trigger 2 (D4); paginação genérica/helpers de runs extras no
github-api.mjs(status e branch bastam);actionlint/validação de YAML nova no CI. - Qualquer migration/UI/access/Consent (não há).
Riscos e mitigação
- R1 — Merge via
GITHUB_TOKENnão cria run depush(anti-recursão). O auto-merge já arma comAUTOMERGE_PAT(fail-closed; OPS71-FLIP) → push cria run. Mitigação: invariante existente nogithub-pr-automerge.mjs+agent-pr-ready-automerge.yml; se um merge bot aparecer sem run, o dispatch manual é o escape e o gatilho de revisitação é adicionar um fallback porpull_request: closed(só com evidência). - R2 — Job
waitingde approval segura o grupodeploy-homeserver(comportamento do GitHub, discussion #67607): odeploy-stagingde runs novos ficapendingenquanto a aprovação de produção do run anterior espera; staging pausa (produção não é tocada; nada publica sem reviewer). Mitigação: documentar no runbook (Gatilho e fluxo + Falhas conhecidas); aprovar/rejeitar rápido. Gatilho de revisitação: se aprovações passarem a demorar sistematicamente e o staging velho doer, avaliar grupos/locks por ambiente — exige rever o lock compartilhado do script (hoje deliberado). - R3 — Corrida residual: merge durante
deploy-productionin_progress. O push cria run, o preflight vê o deploy ativo e skipa; o requeue daquele run já rodou (após o staging). Nada nasce até o próximo merge ou dispatch manual. Mitigação: runbook "Falhas conhecidas"; escape manual. Gatilho de revisitação: primeira ocorrência real → adicionar um segundo requeue apósdeploy-productionverde ou refinar o guard. - R4 — Duplicata trigger 1 × trigger 2 (janela de segundos): run redundante/verify duplicado raro. Mitigação: aceito por D4; script idempotente (
already deployedverde) e deploy serializado pelo grupo. - R5 — YAML do
deploy.ymlinválido derruba os dois caminhos (auto e dispatch usam o arquivo da default branch). Mitigação: mudança pequena e revisada; o merge deste PR é o dogfood (se nenhum run nascer, o próximo merge corrige); manter o dispatch manual documentado. - R6 — Custo hosted extra (~50 min de
verifypor merge/janela). Mitigação: preflight pula quando há deploy ativo; requeue só dispara se o head mudou; cortes de escopo mantêm overifyintocado. - R7 —
actions: writedo token nativo no dispatch: o repo já tem jobs comissues: write/contents: writefuncionando (elevação permitida). Mitigação: conferir no primeiro run; se 403, ajustar Workflow permissions do repo (passo humano, sem secret). - R8 — Backlog de
queue: max(até 100): improvável; o preflight + convergência limitam runs simultâneos. Sem ação; observar. - R9 — Docs vivos espalhados (13+ arquivos, ver seção abaixo): drift vira mentira operacional. Mitigação: checklist obrigatória na fase 4; changelog registra a supersessão do anti-goal da OPS102.
- R10 — Segurança:
pushemmainnão vem de fork; jobs novos são hosted e não tocam o homeserver; nenhum secret novo; permissões novas mínimas (actions: read|write,contents: read); fork-PRs continuam sem CI no repo.
Docs a realinhar (linhas de 2026-09-14; realinhar pela frase, não pelo número)
docs/ops/teqo-1313-deploy.md— l.1 (título "dispatch manual"), l.3-8 (intro "action manual / mergir em main não publica nada"), l.12 (Gatilho), l.54-58 (primeiro deploy/verificação ao vivo), l.75-80 (Staging "mesmo dispatch"), l.225-226 (falhas conhecidas: stale-run/re-dispatch fora de ordem + "already deployed"), l.264 (segurança "apenas dispatch manual"), l.289 (C149 "deploy normal"); adicionar linhas de falha para R2/R3.docs/AGENT-OPS.md— l.3 ("deploy de produção é manual"), l.13, l.19 (diagrama do fluxo), l.25 ("Só humano"), l.82 (tabela de workflows: trigger dodeploy.yml), l.88, l.105, l.111 (parágrafo do deploy).AGENTS.md— l.15 ("publishing is a manualworkflow_dispatch").AGENTS-infra.md— l.5 ("publishing is a manualworkflow_dispatch").README.md— l.12, l.17, l.100 (deploy manual)..agents/rules/agent-pr-workflow.mdc— l.41, l.64 ("There is no deploy from PR CI" continua verdade, mas "manual" muda), l.72.scripts/issue-transition-on-merge.mjs— l.74 (comentário da mensagem "Deploy de produção é manual").- Extras:
.agents/rules/engineering-standards.mdcl.14;.agents/skills/bug-fix/SKILL.mdl.54, l.109;docs/TESTING.mdl.13;.agents/skills/work-issue/execution-pipeline.mdl.43. deploy.yml— header ename.- Novo:
docs/changelog/2026-09-14-ops104.md.
Aceite de engenharia
- Aceite de produto da intenção coberto: (1) merge sem deploy ativo → run nasce no head (trigger 1 + preflight); (2) merge durante verify/staging → run novo no head novo após o staging (trigger 2); (3) merge com produção aguardando review → run nasce (
waitingnão conta no guard), ambos pendentes, approve humano decide; (4) nada publica semverifyfull verde; vermelho = humano resolve (sem retry). - Invariantes AGENTS/engineering-standards: sem migration/UI/access/Consent; sem secret novo;
deploy-homeserver.shintocado (deployScript.unit.spec.tsverde sem edição);verify/ci-pr.yml/guards intocados; produção fail-closed no reviewer;concurrency: deploy-homeserverintocado e só nos deploys; anti-goal OPS102 (stale-run guard) não reintroduzido. - Testes:
tests/unit/deployTrigger.unit.spec.ts(novo) e casos delistWorkflowRuns/getBranchHeadnogithubApi.unit.spec.tsverdes; pin dociSkipInvariants.unit.spec.tsreescrito para "push em main + guard + dispatch" (com comments stripped) e chain OPS103 preservado;deployScript.unit.spec.tssem diff. pnpm gate:fastepnpm test:unitverdes; PR ready; validação pós-merge (fase 5) executada e registrada no changelog/runbook.
Self-score decision-quality
| Critério | Nota | Justificativa |
|---|---|---|
| 1. Decisões caras com rejeitadas | 1,0 | D1–D7 com opções e rejeitadas explícitas (api vs gh vs concurrency; REST vs ls-remote/fetch; owners da lógica; dedupe; job always-run; fail-open/red; escopo do requeue) |
| 2. Cabe no appetite | 1,0 | ~1 dia: 4 fases de ~2h + gates/entrega; nenhuma refatoração do fluxo existente |
| 3. Rabbit holes nomeados | 1,0 | auto-approve/auto-retry, twin, locks por ambiente, refinar "ativo" por fase, dedupe, actionlint fora |
| 4. Depth check (reuso) | 1,0 | estende o client github-api.mjs e o padrão github-pr-flow.mjs de decisão pura; zero twin de módulo; sem dependência nova |
| 5. Outcome preservado | 1,0 | engenharia não altera o aceite: início automático, staging automático, produção humano-aprovada; R2/R3 documentados sem reescrever o outcome |
| Total | 5,0/5 | ≥4 exigido |