mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
feat(OPS104): inicia o deploy no merge, com staging automático e produção com review
- 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
This commit is contained in:
parent
f48c81f625
commit
34c3a1f56f
22 changed files with 736 additions and 108 deletions
|
|
@ -38,7 +38,7 @@ Closes #<N>
|
|||
**Use `pnpm push`, never bare `git push`.** Não depende do Husky estar wired:
|
||||
|
||||
1. `ensure-repo-deps` — `pnpm install` se `node_modules` faltar/stale + `pnpm run prepare` (Husky) se hooks não estiverem apontando para `.husky`
|
||||
2. `pnpm gate:push` — espelho do `ci-pr.yml` sem e2e (OPS72: o e2e é passo **explícito da skill** — antes do push rode localmente os e2e que criou + os da mesma superfície, discricionário via `pnpm test:e2e:affected`; o CI de PR roda só o blast radius `selected`, e o full fica no `verify` do deploy manual), com os `docs-guards` locais (OPS63/OPS85: changelog additions-only + HISTORY congelado + agregado não-commitado, e marcadores de conflito — o escape `changelog-rewrite:` fica só no CI, body do PR)
|
||||
2. `pnpm gate:push` — espelho do `ci-pr.yml` sem e2e (OPS72: o e2e é passo **explícito da skill** — antes do push rode localmente os e2e que criou + os da mesma superfície, discricionário via `pnpm test:e2e:affected`; o CI de PR roda só o blast radius `selected`, e o full fica no `verify` do deploy, que começa no merge — OPS104), com os `docs-guards` locais (OPS63/OPS85: changelog additions-only + HISTORY congelado + agregado não-commitado, e marcadores de conflito — o escape `changelog-rewrite:` fica só no CI, body do PR)
|
||||
3. `git push --no-verify` — só porque o gate já rodou no passo 2 (não é escape hatch)
|
||||
|
||||
```bash
|
||||
|
|
@ -61,7 +61,7 @@ OPS64 history: the Forgejo runner could report the LAST matrix shard's result as
|
|||
|
||||
(`migration-lock` removed 2026-08-12 — the CI validates migration chains via migrate + int on every PR.)
|
||||
|
||||
**There is no deploy from PR CI.** Publishing is a **manual `workflow_dispatch`** (`.github/workflows/deploy.yml`): `verify` runs the full suite (hosted) and only then the `deploy` job (self-hosted runner on the homeserver) publishes `jorgesolla1313.com.br` — migrations applied before the rollout. Do not add a Vercel/Git-integration deploy gate.
|
||||
**There is no deploy from PR CI.** `.github/workflows/deploy.yml` starts on merge to `main` (and on manual `workflow_dispatch` as the escape — OPS104): `preflight` skips the run when another deploy is active, `verify` runs the full suite (hosted), `deploy-staging` publishes automatically, and only the approved `production` environment lets the self-hosted runner on the homeserver publish `jorgesolla1313.com.br` — migrations applied before the rollout. Do not add a Vercel/Git-integration deploy gate.
|
||||
|
||||
When watching CI, use the PR page (or the API) — the safety net arms the server-side auto-merge; `gh` does not exist in this repo (OPS50).
|
||||
|
||||
|
|
@ -69,7 +69,7 @@ Use the required check `CI (PR) / checks` — without it, you are not covering t
|
|||
|
||||
## Promote
|
||||
|
||||
There is no stage→main promote. Merging to `main` is the delivery; publishing is a deliberate manual dispatch with full verification (runbook: `docs/ops/teqo-1313-deploy.md`).
|
||||
There is no stage→main promote. Merging to `main` is the delivery and starts the deploy (staging automatic; production behind the `production` environment approval) with full verification (runbook: `docs/ops/teqo-1313-deploy.md`).
|
||||
|
||||
## Safety net (workflow)
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ Established 2026-07-23 by the engineering-standards hardening (tracker: `docs/IM
|
|||
|
||||
1. **Dead code dies immediately.** After your edits, check what your change orphaned and delete it in the same delivery: run `pnpm exec knip` (files/dependencies are CI-blocking errors; unused exports are warnings being driven to zero). Never leave commented-out code, unused exports, superseded components, or one-shot scripts behind — git history is the archive.
|
||||
2. **Zero warnings.** `pnpm lint` runs with `--max-warnings=0`. Deliberate placeholders use a `_` prefix. Never silence a rule without an inline justification comment.
|
||||
3. **Gate em duas velocidades (desde 2026-07-30, paradigma de agentes paralelos — `docs/AGENT-OPS.md`).** **Fast gate local do agente** (iteração durante a sessão): `pnpm gate:fast` (lint + typecheck + test:unit). **Push canônico:** `pnpm push` — `ensure-repo-deps` (install + Husky em worktrees) + `git push`; o hook pre-push roda `gate:push` → `gate:ci` (espelho serial do `ci-pr.yml` **sem e2e** — OPS72: o e2e é passo **explícito da skill** (`work-issue`/`agent-work-issue`): antes do push rode localmente os e2e que criou + os da mesma superfície, **discricionário** via `pnpm test:e2e:affected`; o CI de PR roda só o blast radius `selected` — nunca `full` — e o full fica no `verify` do deploy manual; **com os `docs-guards` locais** — OPS63/OPS85: changelog additions-only + HISTORY congelado + agregado não-commitado, e marcadores de conflito, mesmos scripts do CI; o escape `changelog-rewrite:` fica só no CI, body do PR). Não use `git push` nu. Fonte única: scripts `gate:*` e `scripts/git-push.mjs` em `package.json`; escape para WIP: `git push --no-verify`. Migrations NÃO são serializadas entre PRs: a cadeia é validada por `pnpm migrate` + `test:int` (incl. `campaignMigrationReconciliation`) em todo PR; rebase antes de `migrate:create` continua obrigatório (o antigo `migration-lock` foi removido em 2026-08-12 — deadlock de CI). Entrega = merge em `main`; publish gated pelo `verify` do `deploy.yml` (dispatch manual — OPS71; não há `ci.yml` verificador de main). **CI vermelho no teu PR é teu, mesmo pré-existente ou de infra** — "fora do escopo da feature" não é critério de parada (política "Dono do PR, dono do CI" em `docs/AGENT-OPS.md`).
|
||||
3. **Gate em duas velocidades (desde 2026-07-30, paradigma de agentes paralelos — `docs/AGENT-OPS.md`).** **Fast gate local do agente** (iteração durante a sessão): `pnpm gate:fast` (lint + typecheck + test:unit). **Push canônico:** `pnpm push` — `ensure-repo-deps` (install + Husky em worktrees) + `git push`; o hook pre-push roda `gate:push` → `gate:ci` (espelho serial do `ci-pr.yml` **sem e2e** — OPS72: o e2e é passo **explícito da skill** (`work-issue`/`agent-work-issue`): antes do push rode localmente os e2e que criou + os da mesma superfície, **discricionário** via `pnpm test:e2e:affected`; o CI de PR roda só o blast radius `selected` — nunca `full` — e o full fica no `verify` do deploy (o run nasce no merge — OPS104); **com os `docs-guards` locais** — OPS63/OPS85: changelog additions-only + HISTORY congelado + agregado não-commitado, e marcadores de conflito, mesmos scripts do CI; o escape `changelog-rewrite:` fica só no CI, body do PR). Não use `git push` nu. Fonte única: scripts `gate:*` e `scripts/git-push.mjs` em `package.json`; escape para WIP: `git push --no-verify`. Migrations NÃO são serializadas entre PRs: a cadeia é validada por `pnpm migrate` + `test:int` (incl. `campaignMigrationReconciliation`) em todo PR; rebase antes de `migrate:create` continua obrigatório (o antigo `migration-lock` foi removido em 2026-08-12 — deadlock de CI). Entrega = merge em `main`; publish gated pelo `verify` do `deploy.yml` (início automático no merge, approve humano na produção — OPS104; não há `ci.yml` verificador de main). **CI vermelho no teu PR é teu, mesmo pré-existente ou de infra** — "fora do escopo da feature" não é critério de parada (política "Dono do PR, dono do CI" em `docs/AGENT-OPS.md`).
|
||||
4. **Run gate commands bare, never piped** (`pnpm test | tail` swallows the exit code). Never run `knip --fix` blind — verify each removal with `git grep -w <symbol>` first (knip still cannot load `payload.config.ts`, so its graph is incomplete; ledgered as P3).
|
||||
|
||||
## Type honesty
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ Fases pesadas são delegadas a sub-agentes com contexto mínimo — o agente pri
|
|||
- [ ] 3. Dispatch sub-agente investigador → causa-raiz
|
||||
- [ ] 4. Fix da causa-raiz + teste de regressão (main agent iterativo)
|
||||
- [ ] 5. Dispatch sub-agente verificador → prova verde
|
||||
- [ ] 6. Gates + PR → merge; bug de prod: deploy manual → confirmação do humano
|
||||
- [ ] 6. Gates + PR → merge; bug de prod: deploy (staging automático, produção após approve) → confirmação do humano
|
||||
- [ ] 7. Dispatch sub-agente estrategista → prevenção classificada
|
||||
- [ ] 8. Implementar prevenção barata agora (a cara fica no post-mortem)
|
||||
- [ ] 9. Dispatch sub-agente escritor → post-mortem + entrada de changelog
|
||||
|
|
@ -106,7 +106,7 @@ Dispatch o **verificador** com o diff + causa-raiz + cenário original. Ele roda
|
|||
## Passo 6 — Fechar e confirmar em prod
|
||||
|
||||
- `pnpm push` → PR no GitHub **Ready** base `main` (`Closes #N` se houver Issue; `Related #N` para PR só de docs — plans-only guard) → auto-merge nativo com o required check `CI (PR) / checks` verde. Siga `agent-pr-workflow.mdc`.
|
||||
- **Bug de prod:** o merge NÃO é o fim. O deploy é **manual** (`workflow_dispatch` de `deploy.yml`, só humano — AGENT-OPS). Peça ao humano para disparar o deploy e **espere a confirmação de que prod se comporta** (o humano relata; você nunca toca o homeserver). Só então o bug está corrigido — registre data/hora da confirmação.
|
||||
- **Bug de prod:** o merge NÃO é o fim. O deploy **começa sozinho** no merge (`push` em `main` dispara o `deploy.yml` — OPS104): o staging publica automaticamente e a **produção só sai com o approve humano** no environment `production`. Peça ao humano para aprovar a produção do run e **espere a confirmação de que prod se comporta** (o humano relata; você nunca toca o homeserver). Só então o bug está corrigido — registre data/hora da confirmação.
|
||||
|
||||
## Passo 7 — Prevenção (sub-agente)
|
||||
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@
|
|||
| Início provável | | quando o bug começou (commit/entrega que o introduziu, se apurado) |
|
||||
| Detecção | | |
|
||||
| Correção mergeada | | |
|
||||
| Deploy | | (bug de prod: dispatch manual do deploy.yml) |
|
||||
| Deploy | | (bug de prod: deploy começa no merge; produção após approve do environment) |
|
||||
| Verificado em prod | | (bug de prod: confirmação do humano) |
|
||||
|
||||
## O bug
|
||||
|
|
|
|||
|
|
@ -40,8 +40,8 @@ antes do deploy**.
|
|||
- **CI/PR:** roda só o **blast radius** detectado (`ci-scope.mjs`, modo
|
||||
`selected`) — nunca `full`. Diff high-risk (schema/lockfile/harness)
|
||||
classifica **`curated`** (OPS86): o PR roda o conjunto curado de e2e —
|
||||
nunca zero; o full fica para o `verify` do deploy manual (antes de
|
||||
publicar) e, nesse mesmo diff, o espelho local roda full. Arquivo de área
|
||||
nunca zero; o full fica para o `verify` do deploy (o run nasce no merge —
|
||||
OPS104) antes de publicar e, nesse mesmo diff, o espelho local roda full. Arquivo de área
|
||||
de risco sem entry no manifesto (`src/utilities/access`, `src/lib/schemas`,
|
||||
`campaignPushClient`, `src/utilities/ai`) classifica `unmapped-risk` e o PR
|
||||
**falha** listando os arquivos — a correção é adicionar a entry no
|
||||
|
|
|
|||
128
.github/workflows/deploy.yml
vendored
128
.github/workflows/deploy.yml
vendored
|
|
@ -1,49 +1,67 @@
|
|||
# Manual deploy (OPS71 — GitHub Actions; staging + production since OPS103).
|
||||
# `workflow_dispatch` only: there is NO automatic main verifier anymore
|
||||
# (ci.yml eliminated in the gate 2026-08-19) — publishing is a deliberate
|
||||
# operator act, and this workflow runs the FULL suite (no scope skips, e2e
|
||||
# included) before any deploy job touches the homeserver.
|
||||
# Deploy (OPS71 — GitHub Actions; staging + production since OPS103;
|
||||
# automatic start since OPS104).
|
||||
#
|
||||
# THREE jobs, same workflow (OPS103):
|
||||
# STARTS BY ITSELF: a `push` to `main` starts the run (OPS104 trigger 1) —
|
||||
# the `preflight` job skips it when another deploy run is already
|
||||
# queued/in_progress (a run `waiting` on the production approval does NOT
|
||||
# count: a merge in that window starts a new run and both stay pending).
|
||||
# `workflow_dispatch` is kept as the manual escape (rollback/re-dispatch) and
|
||||
# never goes through the guard.
|
||||
#
|
||||
# REQUEUES ITSELF: after a green `deploy-staging`, the hosted `requeue` job
|
||||
# compares main's head with the run's SHA and dispatches a new run from the
|
||||
# new head when a merge landed mid-flight (OPS104 trigger 2). It stays OUTSIDE
|
||||
# the `deploy-homeserver` group — a run waiting on the production approval
|
||||
# must not block the requeue.
|
||||
#
|
||||
# Jobs (OPS103/OPS104):
|
||||
# preflight — ubuntu-latest: push-only guard (OPS104 trigger 1).
|
||||
# verify — ubuntu-latest (hosted): full cascade (guards for PR
|
||||
# contracts are left out — they gate PRs, not
|
||||
# production). Untouched by OPS103; it verifies the
|
||||
# COMMIT once for both environments.
|
||||
# deploy-staging — needs: [verify], environment: staging (no reviewer:
|
||||
# the dispatch itself is the deliberate act). Runs the
|
||||
# the run itself is the deliberate act). Runs the
|
||||
# same script with TEQO_ENV=staging (teqo-staging,
|
||||
# 127.0.0.1:1314).
|
||||
# deploy-production — needs: [deploy-staging], environment: production
|
||||
# (required reviewer fsolla, configured in the repo).
|
||||
# A red staging fail-closes production. Same script,
|
||||
# TEQO_ENV=production (teqo-1313, 127.0.0.1:1313).
|
||||
# requeue — hosted, after deploy-staging: re-dispatches when main
|
||||
# advanced (OPS104 trigger 2).
|
||||
#
|
||||
# Both deploy jobs run on the GitHub self-hosted runner ON the homeserver
|
||||
# (labels self-hosted + homeserver): the script executes locally — no SSH
|
||||
# hop, no workstation involvement. Deliberately wired ONLY here (manual
|
||||
# dispatch), never in ci-pr.yml: a self-hosted job is code execution on the
|
||||
# production machine, and only an explicit dispatch with the full verify
|
||||
# suite green can reach it. The hosted job never reaches the homeserver; the
|
||||
# self-hosted job never counts hosted minutes.
|
||||
# hop, no workstation involvement. Never moved into ci-pr.yml: a self-hosted
|
||||
# job is code execution on the production machine, and only a run with the
|
||||
# full verify suite green can reach it. The hosted job never reaches the
|
||||
# homeserver; the self-hosted job never counts hosted minutes.
|
||||
#
|
||||
# The environment-specific builds CANNOT leave the homeserver (migrate
|
||||
# pre-build on the env DB — OPS66 — and the compose network / local registry
|
||||
# localhost:5000), which is exactly why the hosted job only verifies and the
|
||||
# self-hosted jobs only build + deploy. Deploy is idempotent ("already
|
||||
# deployed" guard in deploy-homeserver.sh) and serialized by flock per
|
||||
# environment; the `concurrency` group below serializes the two shared
|
||||
# localhost:5000), which is exactly why the hosted jobs only verify/decide
|
||||
# and the self-hosted jobs only build + deploy. Deploy is idempotent
|
||||
# ("already deployed" guard in deploy-homeserver.sh) and serialized by flock
|
||||
# per environment; the `concurrency` group below serializes the two shared
|
||||
# homeserver compose/workspace consumers across runs without cancelling an
|
||||
# in-flight deploy (the staged `needs` chain already serializes them inside
|
||||
# one run).
|
||||
#
|
||||
# The deploy jobs fetch the repo from github.com (public) — TEQO_REPO_URL in
|
||||
# deploy-homeserver.sh now defaults there; the script runs to completion for
|
||||
# the dispatched SHA even if main advances during verify (OPS102 — publishing
|
||||
# is a deliberate operator act).
|
||||
# deploy-homeserver.sh defaults there; the script runs to completion for the
|
||||
# SHA of the run even if main advances during verify (OPS102).
|
||||
#
|
||||
# OPS104 supersedes the OPS102 "manual-only" anti-goal: only the START of
|
||||
# the run is automatic. Production still publishes exclusively through the
|
||||
# `production` environment approval; there is no auto-approve, no auto-retry
|
||||
# and no cancel-old-runs automation.
|
||||
|
||||
name: Deploy (manual)
|
||||
name: Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
|
|
@ -54,7 +72,30 @@ env:
|
|||
NEXT_PUBLIC_SITE_URL: http://localhost:3000
|
||||
|
||||
jobs:
|
||||
preflight:
|
||||
# OPS104 trigger 1 guard: skip this push when another deploy run is
|
||||
# already queued/in_progress (a run `waiting` on the production approval
|
||||
# does NOT count). Manual dispatches skip the guard (the step is
|
||||
# push-only) and the output default keeps the dispatch path flowing.
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
outputs:
|
||||
should_deploy: ${{ steps.guard.outputs.should_deploy || 'true' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Decide whether this push should deploy
|
||||
id: guard
|
||||
if: github.event_name == 'push'
|
||||
run: node scripts/deploy-preflight.mjs
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
|
||||
verify:
|
||||
needs: [preflight]
|
||||
if: needs.preflight.outputs.should_deploy == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 50
|
||||
services:
|
||||
|
|
@ -160,10 +201,10 @@ jobs:
|
|||
|
||||
deploy-staging:
|
||||
name: deploy staging (teqo-staging)
|
||||
# OPS103: same SHA, same verify, own environment — no reviewer (the
|
||||
# manual dispatch is the deliberate act); production is the approval that
|
||||
# matters. The `main` guard makes a dispatch from a branch/tag a no-op
|
||||
# for deploys (the runbook's dispatch ref is always `main`).
|
||||
# OPS103 (+OPS104): same SHA, same verify, own environment — no reviewer
|
||||
# (the run was started deliberately: push to main or manual dispatch);
|
||||
# production is the approval that matters. The `main` guard makes a
|
||||
# dispatch from a branch/tag a no-op for deploys.
|
||||
needs: [verify]
|
||||
if: needs.verify.result == 'success' && github.ref == 'refs/heads/main'
|
||||
environment: staging
|
||||
|
|
@ -185,16 +226,39 @@ jobs:
|
|||
- name: Deploy teqo-staging on the homeserver
|
||||
run: bash scripts/deploy-homeserver.sh "$GITHUB_SHA"
|
||||
|
||||
requeue:
|
||||
name: requeue when main advanced
|
||||
# OPS104 trigger 2: after a green staging, dispatch a fresh run from
|
||||
# main's new head when a merge landed mid-flight. Hosted and OUTSIDE the
|
||||
# `deploy-homeserver` group: a run `waiting` on the production approval
|
||||
# holds that group and must not block the requeue. `actions: write` is the
|
||||
# documented anti-recursion exception — workflow_dispatch events DO create
|
||||
# runs for the built-in GITHUB_TOKEN (no secret needed).
|
||||
needs: [deploy-staging]
|
||||
if: needs.deploy-staging.result == 'success' && github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
actions: write
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Requeue when main advanced during the run
|
||||
run: node scripts/deploy-requeue.mjs
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
|
||||
deploy-production:
|
||||
name: deploy production (teqo-1313)
|
||||
# OPS53 + OPS71 + OPS103. Runs on the GitHub self-hosted runner ON the
|
||||
# homeserver (labels self-hosted + homeserver): the script executes
|
||||
# locally — no SSH hop, no workstation involvement. Deliberately wired
|
||||
# ONLY here (manual dispatch), never in ci-pr.yml: a self-hosted job is
|
||||
# code execution on the production machine, and only an explicit dispatch
|
||||
# with the full verify suite green can reach it. `needs: [deploy-staging]`
|
||||
# makes a red staging fail-close production; the environment `production`
|
||||
# (required reviewer, configured in the repo) is the approval gate.
|
||||
# OPS53 + OPS71 + OPS103 + OPS104. Runs on the GitHub self-hosted runner
|
||||
# ON the homeserver (labels self-hosted + homeserver): the script executes
|
||||
# locally — no SSH hop, no workstation involvement. Never wired in
|
||||
# ci-pr.yml: a self-hosted job is code execution on the production
|
||||
# machine, and only a run with the full verify suite green can reach it
|
||||
# (the automatic start and the manual dispatch share the same gate).
|
||||
# `needs: [deploy-staging]` makes a red staging fail-close production; the
|
||||
# environment `production` (required reviewer, configured in the repo) is
|
||||
# the approval gate — and the ONLY one.
|
||||
needs: [deploy-staging]
|
||||
if: needs.deploy-staging.result == 'success' && github.ref == 'refs/heads/main'
|
||||
environment: production
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
## Deploy & CI
|
||||
|
||||
Production is self-hosted on the homeserver (`jorgesolla1313.com.br`, behind the Cloudflare tunnel; container/database `teqo-1313`/`teqo_1313`). **There is no Vercel deploy anymore** — the Vercel deploy job, `vercel-promote.yml`, the Vercel scripts/`vercel.json` and the `VERCEL_*` repo secrets were removed in OPS50 (2026-08-17). **CI/PR/deploy AND the ISSUE TRACKER live on GitHub (OPS71 + OPS76, 2026-08-21)** (`github.com/fsolla/teqo/issues` — labels, claims, `pnpm issue`/`agent:*` and the post-merge flips talk to the GitHub API; GitHub is the single host). The PR CI (`ci-pr.yml`, job único `checks` — OPS62) runs the same cascade as always (guards → lint → format → typecheck → knip → cycles → unit → int → build → e2e selected/afetado, skips via `ci-scope.mjs`); its check-run `CI (PR) / checks` is the required check of `main`. **There is NO main verifier (`ci.yml`) anymore**: publishing is a **manual `workflow_dispatch`** (`.github/workflows/deploy.yml`) with three jobs — `verify` (hosted `ubuntu-latest`, suíte **full** incl. e2e full, rodado uma vez por commit) → `deploy-staging` (`environment: staging`, OPS103) → `deploy-production` (`environment: production` com required reviewer, `needs: [deploy-staging]`): the GitHub self-hosted runner ON the homeserver executes `scripts/deploy-homeserver.sh` **locally** (no SSH; outbound-only runner, works behind the tunnel; the hosted job never touches the homeserver). O script é único e parametrizado por `TEQO_ENV` (`production` default intocado; `staging` = `teqo-staging`/`teqo_staging`/`~/stack/teqo-staging.env`/`127.0.0.1:1314`, bucket `teqo-media-staging`; envs de staging só no homeserver, nunca como GitHub secrets; staging não é indexável). The remote Docker deployment is gone; the production container runs the monolith directly. The Docker Compose file is used ONLY for local dev databases. The `run-deploy.yml` (OPS38/OPS46) was deleted in OPS71. There is no `run-deploy.yml` or equivalent in GitHub Actions — deploy is `deploy.yml` only.
|
||||
Production is self-hosted on the homeserver (`jorgesolla1313.com.br`, behind the Cloudflare tunnel; container/database `teqo-1313`/`teqo_1313`). **There is no Vercel deploy anymore** — the Vercel deploy job, `vercel-promote.yml`, the Vercel scripts/`vercel.json` and the `VERCEL_*` repo secrets were removed in OPS50 (2026-08-17). **CI/PR/deploy AND the ISSUE TRACKER live on GitHub (OPS71 + OPS76, 2026-08-21)** (`github.com/fsolla/teqo/issues` — labels, claims, `pnpm issue`/`agent:*` and the post-merge flips talk to the GitHub API; GitHub is the single host). The PR CI (`ci-pr.yml`, job único `checks` — OPS62) runs the same cascade as always (guards → lint → format → typecheck → knip → cycles → unit → int → build → e2e selected/afetado, skips via `ci-scope.mjs`); its check-run `CI (PR) / checks` is the required check of `main`. **There is NO main verifier (`ci.yml`) anymore**: a merge to `main` starts the deploy run automatically (OPS104 supersedes the OPS102 manual-only anti-goal; `workflow_dispatch` stays as the manual escape) — the verifier IS the deploy's `verify` job. The deploy workflow has five jobs: `preflight` (hosted; `scripts/deploy-preflight.mjs` skips the run when another deploy is `queued`/`in_progress` — a `waiting` production approval does not count) → `verify` (hosted `ubuntu-latest`, suíte **full** incl. e2e full, rodado uma vez por commit) → `deploy-staging` (`environment: staging`, OPS103) → `requeue` (hosted, outside the deploy concurrency group; `scripts/deploy-requeue.mjs` re-dispatches a fresh run when `main` advanced — OPS104) and `deploy-production` (`environment: production` com required reviewer, `needs: [deploy-staging]`): the GitHub self-hosted runner ON the homeserver executes `scripts/deploy-homeserver.sh` **locally** (no SSH; outbound-only runner, works behind the tunnel; the hosted job never touches the homeserver). O script é único e parametrizado por `TEQO_ENV` (`production` default intocado; `staging` = `teqo-staging`/`teqo_staging`/`~/stack/teqo-staging.env`/`127.0.0.1:1314`, bucket `teqo-media-staging`; envs de staging só no homeserver, nunca como GitHub secrets; staging não é indexável). The remote Docker deployment is gone; the production container runs the monolith directly. The Docker Compose file is used ONLY for local dev databases. The `run-deploy.yml` (OPS38/OPS46) was deleted in OPS71. There is no `run-deploy.yml` or equivalent in GitHub Actions — deploy is `deploy.yml` only.
|
||||
|
||||
**Guarding `pnpm push` (OPS24+):** every PR push goes through `scripts/git-push.mjs`, which runs `ensure-repo-deps` (install + Husky in worktrees — OPS51), then `gate:push` (the pre-push hook), which chains `gate:ci` — a serialized mirror of the CI `checks` job (**without e2e**: OPS72 moved e2e to an explicit skill step) — plus the `docs-guards` (changelog entries additions-only + frozen HISTORY snapshot + uncommitted regenerable aggregate — OPS63/OPS85; conflict markers; the rewrite escape stays in CI only). The hook blocks `git push` on failure; escape: `git push --no-verify` (WIP only). There is no YAML `push` gate — the hook is the gate. **CI red on your PR is yours**, even pre-existing or infra — "out of feature scope" is not a stop condition (policy "Dono do PR, dono do CI" in `docs/AGENT-OPS.md`).
|
||||
|
||||
|
|
@ -18,7 +18,7 @@ Production is self-hosted on the homeserver (`jorgesolla1313.com.br`, behind the
|
|||
|
||||
1. Confirm `DATABASE_URL` is `postgresql://teqo:teqo@localhost:5432/teqo` and `.env.test` points to `teqo_test`; never use the homeserver prod DB for development, tests, E2E, or local builds. In a provisioned worktree (`pnpm worktree next`) the values come from its `.env.local` / `.env.test.local` (`teqo_wt<slot>` / `teqo_wt<slot>_test`, dev port `3100+slot`) — read them there instead of assuming the main-repo names.
|
||||
2. Run `pnpm db:start`, `pnpm migrate:status`, and `DATABASE_URL=postgresql://teqo:teqo@localhost:5432/teqo_test pnpm migrate:status`; every migration must show `Ran: Yes`.
|
||||
3. Run `pnpm generate:types`, `pnpm generate:importmap` when components changed — **com as envs `S3_*` setadas (valores dummy ok) enquanto o plugin `s3Storage` for condicional: sem elas o importMap fica órfão do `S3ClientUploadHandler` e o admin abre em branco em produção (OPS69, P0 em 2026-08-19)** —, `pnpm exec tsc --noEmit`, `pnpm lint` (zero warnings — the script enforces `--max-warnings=0`), `pnpm format:check` (Prettier is the formatting authority; `pnpm format` fixes), `pnpm exec knip` (dead files/dependencies are CI-blocking; delete what your change orphaned), `pnpm check:cycles` (madge — zero circular imports), `pnpm test` (unit + int), and `pnpm build` against the local database. Payload CLI scripts (`migrate`, `generate:*`, `payload`) and the build migrate step use `--conditions=react-server` so `server-only` modules load correctly outside Next.js. **Política de e2e (OPS72 + OPS86):** o e2e **não roda no gate local** (`gate:push`) — é passo **explícito da skill** (`work-issue`/`agent-work-issue`): antes do push o agente roda localmente os e2e que **criou** + os da **mesma superfície afetada** — **discricionário** (o agente decide quais; ferramenta: `pnpm test:e2e:affected`, espelho local do classifier — gera `--no-deps` para runs filtrados, S6-FOLLOWUP: no dev mode a cadeia de dependencies dos projects arrasta os arquivos das dependências via closure; filtros manuais locais: `pnpm test:e2e --no-deps -- <path>` ou `--no-deps --project=<família>`; limitação #72: `--no-deps` + projetos paralelos colide no `seedTestUser` → `--workers=1` ou a cadeia padrão de projetos). O **CI de PR roda apenas o blast radius** (via `ci-scope.mjs` — **nunca `full`**): diff mapeado roda `selected`, diff **high-risk** (coleções/globals/migrações/lockfile/harness) roda o **`curated`** — conjunto fixo de 5 specs (`campaignPermissionProfile`, `campaignDemandVisibility`, `campaignAiTranscribe`, `campaignAgendaFeed`, `campaignNewsletter`, congelado por pin em `e2eAffectedManifest.unit.spec.ts`) — **nunca zero e2e no PR**; arquivo de **área de risco sem entry no manifesto** (`src/utilities/access`, `src/lib/schemas`, `campaignPushClient`, `src/utilities/ai`) classifica **`unmapped-risk` e o PR FALHA** listando os arquivos (fail-closed; resolver = adicionar entry no `E2E_AFFECTED_MANIFEST`); src/ não-mapeado não-risco roda o smoke `campaignHomeActions`. Os passos unit/int `changed` passam por `scripts/vitest-changed-or-full.mjs`: seleção vazia cai em **suíte full** — nunca "0 testes, verde". O e2e **full** roda no `verify` do deploy manual (gateia merge e deploy — rede de segurança final antes de publicar); localmente, diff high-risk segue rodando full no `pnpm test:e2e:affected`.
|
||||
3. Run `pnpm generate:types`, `pnpm generate:importmap` when components changed — **com as envs `S3_*` setadas (valores dummy ok) enquanto o plugin `s3Storage` for condicional: sem elas o importMap fica órfão do `S3ClientUploadHandler` e o admin abre em branco em produção (OPS69, P0 em 2026-08-19)** —, `pnpm exec tsc --noEmit`, `pnpm lint` (zero warnings — the script enforces `--max-warnings=0`), `pnpm format:check` (Prettier is the formatting authority; `pnpm format` fixes), `pnpm exec knip` (dead files/dependencies are CI-blocking; delete what your change orphaned), `pnpm check:cycles` (madge — zero circular imports), `pnpm test` (unit + int), and `pnpm build` against the local database. Payload CLI scripts (`migrate`, `generate:*`, `payload`) and the build migrate step use `--conditions=react-server` so `server-only` modules load correctly outside Next.js. **Política de e2e (OPS72 + OPS86):** o e2e **não roda no gate local** (`gate:push`) — é passo **explícito da skill** (`work-issue`/`agent-work-issue`): antes do push o agente roda localmente os e2e que **criou** + os da **mesma superfície afetada** — **discricionário** (o agente decide quais; ferramenta: `pnpm test:e2e:affected`, espelho local do classifier — gera `--no-deps` para runs filtrados, S6-FOLLOWUP: no dev mode a cadeia de dependencies dos projects arrasta os arquivos das dependências via closure; filtros manuais locais: `pnpm test:e2e --no-deps -- <path>` ou `--no-deps --project=<família>`; limitação #72: `--no-deps` + projetos paralelos colide no `seedTestUser` → `--workers=1` ou a cadeia padrão de projetos). O **CI de PR roda apenas o blast radius** (via `ci-scope.mjs` — **nunca `full`**): diff mapeado roda `selected`, diff **high-risk** (coleções/globals/migrações/lockfile/harness) roda o **`curated`** — conjunto fixo de 5 specs (`campaignPermissionProfile`, `campaignDemandVisibility`, `campaignAiTranscribe`, `campaignAgendaFeed`, `campaignNewsletter`, congelado por pin em `e2eAffectedManifest.unit.spec.ts`) — **nunca zero e2e no PR**; arquivo de **área de risco sem entry no manifesto** (`src/utilities/access`, `src/lib/schemas`, `campaignPushClient`, `src/utilities/ai`) classifica **`unmapped-risk` e o PR FALHA** listando os arquivos (fail-closed; resolver = adicionar entry no `E2E_AFFECTED_MANIFEST`); src/ não-mapeado não-risco roda o smoke `campaignHomeActions`. Os passos unit/int `changed` passam por `scripts/vitest-changed-or-full.mjs`: seleção vazia cai em **suíte full** — nunca "0 testes, verde". O e2e **full** roda no `verify` do deploy (o run nasce no merge — OPS104; a produção só publica com o approve do reviewer — gateia merge e deploy, rede de segurança final antes de publicar); localmente, diff high-risk segue rodando full no `pnpm test:e2e:affected`.
|
||||
- Never run `knip --fix` blind: when knip's module graph is incomplete (it still cannot load `payload.config.ts` — ledgered as P3), `--fix` deletes exports that migrations/tests still reference. Verify each removal (e.g. `git grep -w <symbol>`) before deleting.
|
||||
- Run gate commands bare — never piped (`pnpm test | tail` swallows the exit code, and a real failure was masked exactly this way during Pass 2). Large outputs are captured by the terminal; read them from there.
|
||||
4. Scan every edited runtime source file with Aikido and resolve new findings before handoff. Do not expand hardening work into unrelated pre-existing findings without an explicit scope change.
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -9,12 +9,12 @@
|
|||
> 1. O agente roda `pnpm agent:claim` (ou `pnpm worktree next --issue N`) e pega a próxima Issue `ready` por prioridade (`prio:P0..P3`). O **tracker de Issues, o código, os PRs e o CI vivem no GitHub** (`github.com/fsolla/teqo`).
|
||||
> 2. Ele implementa, roda o fast gate (`lint + typecheck + unit`) e `pnpm push -u origin HEAD` — abre a PR para `main` (Ready, `Closes #N`).
|
||||
> 3. CI verde (`CI (PR) / checks`) → o safety net `agent-pr-ready-automerge.yml` arma o **auto-merge nativo** (rebase) — o servidor só mergea com o required check verde.
|
||||
> 4. Publicar é **manual**: dispatch de `deploy.yml` roda a suíte full (`verify`) e o job `deploy` publica no homeserver (`jorgesolla1313.com.br`, runner self-hosted) — migrações aplicadas antes do rollout. Nada é automático pós-merge.
|
||||
> 4. Deploy **começa automático**: o merge em `main` dispara o `deploy.yml` (suíte full no `verify`) → staging publica sozinho → **produção só com o approve do reviewer** no environment `production` (`jorgesolla1313.com.br`, runner self-hosted) — migrações aplicadas antes do rollout; o staging velho se atualiza via requeue (OPS104). `workflow_dispatch` segue como escape manual.
|
||||
> 5. Secrets humanos (uma vez): `GITHUB_TOKEN` (PAT com escopo `repo` + `issues: write`) para os scripts de agente/PR (`pnpm issue`/`agent:*`, `github-pr.mjs`); envs de prod em `~/stack/teqo-1313.env` no homeserver. Branch protection de `main` já aplicada (reaplicar: `pnpm configure:branch-protection`).
|
||||
>
|
||||
> Comandos: `pnpm agent:claim | agent:register | agent:prioritize | agent:file-miss | worktree next` e `pnpm db:seed:minimal`.
|
||||
> Labels: estado `ready|in-progress|blocked|done|in-prod`, `prio:P0..P3`, `kind:*`, `needs:migration|consent`, `requirements-changed`.
|
||||
> **Agente faz sozinho:** claim → implementar → PR → main. **Só humano:** deploy (dispatch), envs do homeserver, branch protection, runbook de rollback.
|
||||
> **Agente faz sozinho:** claim → implementar → PR → main (o merge já inicia o deploy de staging). **Só humano:** aprovar a produção no environment do run, envs do homeserver, branch protection, runbook de rollback.
|
||||
> Tudo em detalhe: [`docs/AGENT-OPS.md`](docs/AGENT-OPS.md) · CI: `.github/workflows/ci-pr.yml` · Deploy: `.github/workflows/deploy.yml`.
|
||||
|
||||
Teqo starts as the official digital platform for **deputado Jorge Solla** and evolves into a **white-label civic engagement platform** for politicians in Brazil.
|
||||
|
|
@ -97,7 +97,7 @@ To change the schema:
|
|||
3. Review and commit the generated files in `src/migrations/` (both `.ts` and `.json`, plus `index.ts`).
|
||||
4. Apply it locally: `pnpm migrate` (check status anytime with `pnpm migrate:status`).
|
||||
|
||||
**Deploying to production:** merges to `main` with production changes are deployed by the `deploy` job of `ci.yml` (windowed — see `docs/AGENT-OPS.md`); the remote script (`scripts/deploy-homeserver.sh`) builds on the homeserver and applies pending migrations through the compose maintenance service `teqo-1313-migrate` **before** the rollout. Do not run migrations against production by hand. Runbook (rollback, known failures): `docs/ops/teqo-1313-deploy.md`.
|
||||
**Deploying to production:** merges to `main` trigger `deploy.yml` automatically (OPS104) — the `verify` job runs the full suite, `deploy-staging` publishes on its own, and `deploy-production` waits for the `production` environment approval. The remote script (`scripts/deploy-homeserver.sh`) builds on the homeserver and applies pending migrations through the compose maintenance service `teqo-1313-migrate` **before** the rollout. Do not run migrations against production by hand. Runbook (rollback, known failures): `docs/ops/teqo-1313-deploy.md`.
|
||||
|
||||
### Running tests
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# AGENT-OPS — operação do paradigma de agentes paralelos
|
||||
|
||||
Uma página. Norte: simplicidade (GitHub = tracker E Actions — OPS76), agente entrega em `main`, deploy de produção é **manual** com verificação full (OPS71).
|
||||
Uma página. Norte: simplicidade (GitHub = tracker E Actions — OPS76), agente entrega em `main`, deploy **começa automático** no merge e a produção continua **manual** (reviewer) com verificação full (OPS71 + OPS104).
|
||||
|
||||
## Ambientes de dados (test ladder — nunca misturar)
|
||||
|
||||
|
|
@ -10,19 +10,19 @@ Uma página. Norte: simplicidade (GitHub = tracker E Actions — OPS76), agente
|
|||
| **Staging** (`teqo_staging`, homeserver) | sintético (`db:seed:minimal`) | job `deploy-staging` do `deploy.yml` (OPS103); inspeção humana antes da aprovação de produção |
|
||||
| **Prod** (`teqo_1313`, homeserver) | real | job `deploy-production` do `deploy.yml` (OPS53 + OPS71 + OPS103) |
|
||||
|
||||
Agentes **nunca** recebem `DATABASE_URL` de prod/staging e nunca setam `ALLOW_REMOTE_DB`. Não há mais smoke `stage` / `ALLOW_STAGE_TEST_DB` / `ci-stage.yml` — o staging da OPS103 é **alvo de promoção de um SHA de `main`** (dispatch manual → staging → aprovação → produção), nunca destino de PR.
|
||||
Agentes **nunca** recebem `DATABASE_URL` de prod/staging e nunca setam `ALLOW_REMOTE_DB`. Não há mais smoke `stage` / `ALLOW_STAGE_TEST_DB` / `ci-stage.yml` — o staging da OPS103 é **alvo de promoção de um SHA de `main`** (merge em `main` ou `workflow_dispatch` → staging → aprovação → produção), nunca destino de PR.
|
||||
|
||||
## Fluxo
|
||||
|
||||
```text
|
||||
claim → feature branch → PR --base main (GitHub) → CI PR green (cascade + skips) → auto-merge nativo em main (rebase)
|
||||
main → (opcional, deliberado) workflow_dispatch deploy.yml → verify full suite verde → deploy-staging → aprovação do environment production → deploy-production (OPS53 + OPS103)
|
||||
main → push dispara deploy.yml sozinho (ou workflow_dispatch manual) → preflight (pula se já há deploy ativo) → verify full verde → deploy-staging → aprovação do environment production → deploy-production; requeue re-despacha se main andou no meio (OPS53 + OPS103 + OPS104)
|
||||
```
|
||||
|
||||
**Skills:** `plan-issue` (intenção + Issues) → `work-issue` (humano: Issue já claimada → impl plan → confirmação → execução) ou `agent-work-issue` (pool: já claimada → impl plan → execução sem pausa) → `/simplify` → `capture-review-debts` → PR `--base main` (GitHub) → `project-status`. `docs/roadmap.md` = legado congelado; fonte canônica = Issues do GitHub (`github.com/fsolla/teqo/issues`).
|
||||
|
||||
- **Agente faz sozinho:** claim (pool-supervisor no pool; humano via `pnpm agent:claim` fora da sessão — OPS33: `worktree next` claima) → implementa → **e2e local afetado** (OPS72, discricionário: roda os e2e que criou + os da mesma superfície — `pnpm test:e2e:affected`; ver skill) → **`pnpm push`** (origin = GitHub) → PR via `node scripts/github-pr.mjs` **Ready** (nunca draft) com `Closes #N` → o safety net `agent-pr-ready-automerge.yml` arma o **auto-merge nativo do GitHub** (`enablePullRequestAutoMerge`, rebase) — o servidor mergea quando o required check `CI (PR) / checks` fica verde. Regra always-on: `.agents/rules/agent-pr-workflow.mdc`. Em Cursor Cloud: `ManagePullRequest` com `draft: false` (o default draft da tool **não** vale neste repo) — o merge continua pelo safety net.
|
||||
- **Só humano:** envs de produção no homeserver (`~/stack/teqo-1313.env`, fora do repo — o deploy as lê via BuildKit secrets), `GITHUB_TOKEN` local (PAT) para os scripts GitHub, secrets no repo GitHub, `pnpm configure:branch-protection` (idempotente; reaplicar se o drift voltar), runbook manual/rollback (`docs/ops/teqo-1313-deploy.md`).
|
||||
- **Só humano:** aprovar o environment `production` no run (o único gate de publicação — OPS104 nunca auto-aprova), envs de produção no homeserver (`~/stack/teqo-1313.env`, fora do repo — o deploy as lê via BuildKit secrets), `GITHUB_TOKEN` local (PAT) para os scripts GitHub, secrets no repo GitHub, `pnpm configure:branch-protection` (idempotente; reaplicar se o drift voltar), runbook manual/rollback (`docs/ops/teqo-1313-deploy.md`).
|
||||
|
||||
### Dono do PR, dono do CI
|
||||
|
||||
|
|
@ -76,16 +76,16 @@ Arquivos de "lista" que N agentes escrevem em paralelo (plans, migrações, cata
|
|||
|
||||
## CI por alvo (GitHub Actions, OPS71)
|
||||
|
||||
| Workflow | Trigger | Banco | Passos |
|
||||
| ------------------------------------ | --------------------------------------------------------------- | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `ci-pr.yml` | PR → `main` (same-repo) | 1× service Postgres 17 | Job único `checks` sequencial (OPS62 X1): guards → lint→format→typecheck→knip→cycles→unit→int→build→e2e; skips via `ci-scope.mjs` (step `scope`); **fail-fast estrutural** — step vermelho interrompe o job, o run falha; `concurrency` nativo cancela o run em push novo; e2e **apenas blast radius** — `selected` ou, em high-risk, o conjunto `curated` (nunca `full` — OPS72/OPS86); área de risco sem mapping falha `unmapped-risk`; unit/int `changed` com seleção vazia cai em full (`vitest-changed-or-full.mjs`) — em 1 processo com `PLAYWRIGHT_WORKERS=4`; 1 service `postgres-int` (localhost:5432) com **`pnpm db:reset`** (drop→migrate→seed:minimal) entre int e build/e2e (OPS88 — substitui o 2º container); fork-PR excluída no `if:` do job (repo público — não queima minutes) |
|
||||
| `deploy.yml` | **`workflow_dispatch` manual** | 1× service Postgres 17 | **Três jobs (OPS103):** `verify` (hosted, suíte **full** sem skips — lint→format→typecheck→knip→cycles→unit→int→`db:reset`→build `.next-e2e`→e2e full; OPS88: um build só, o default saiu) → `deploy-staging` (`needs: [verify]`, `environment: staging`, `TEQO_ENV=staging`) → `deploy-production` (`needs: [deploy-staging]`, `environment: production` com required reviewer, `TEQO_ENV=production`); ambos os deploys rodam em `runs-on: [self-hosted, homeserver]` e executam `scripts/deploy-homeserver.sh <sha>` **localmente** no homeserver (sem SSH; roda até o fim com o SHA do dispatch mesmo se `main` andar durante o verify — OPS102; idempotente; um `flock` compartilhado + `concurrency: deploy-homeserver` com `queue: max`; migrator→migrate→runner→rollout→smoke) — staging vermelho fail-closa produção |
|
||||
| `issue-done-on-main-merge.yml` | PR merged → `main` (same-repo) + **dispatch manual (recovery)** | — | `Closes`/`Fixes` → `done` + `in-prod` + **close** **no GitHub** (token `GITHUB_TOKEN`; body do PR via `PR_BODY` do evento GitHub — no dispatch, busca via `gh` com o mesmo contrato: merged + main + same-repo) |
|
||||
| `plan-issue-ready-on-main-merge.yml` | PR merged → `main` (same-repo) + **dispatch manual (recovery)** | — | `Related #N` aguardando plano → `ready` **no GitHub** (OPS18; soft-skip; mesmo token) |
|
||||
| `agent-pr-ready-automerge.yml` | PR same-repo → `main` (open/sync/…) | — | Safety net via `github-pr-automerge.mjs` (plain Node, REST+GraphQL): draft `cursor/*` → Ready (PATCH); arm `enablePullRequestAutoMerge` (rebase) **com o `AUTOMERGE_PAT`** (fail-closed — nunca o `GITHUB_TOKEN` nativo, cujo merge como `github-actions[bot]` não cria runs no `closed`; OPS71-FLIP) — o **servidor** espera o required check `CI (PR) / checks` (o OPS64 "rollup pode mentir" é estrutural aqui: job único posta um check-run honesto); draft não-`cursor/*` = veto, skip |
|
||||
| `archive-cursor-agent.yml` | dispatch manual (ops) | — | Helper: cancela run + arquiva um agente Cursor Cloud (`CURSOR_API_KEY`) — **dormente (OPS65)** como o pool |
|
||||
| Workflow | Trigger | Banco | Passos |
|
||||
| ------------------------------------ | --------------------------------------------------------------- | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `ci-pr.yml` | PR → `main` (same-repo) | 1× service Postgres 17 | Job único `checks` sequencial (OPS62 X1): guards → lint→format→typecheck→knip→cycles→unit→int→build→e2e; skips via `ci-scope.mjs` (step `scope`); **fail-fast estrutural** — step vermelho interrompe o job, o run falha; `concurrency` nativo cancela o run em push novo; e2e **apenas blast radius** — `selected` ou, em high-risk, o conjunto `curated` (nunca `full` — OPS72/OPS86); área de risco sem mapping falha `unmapped-risk`; unit/int `changed` com seleção vazia cai em full (`vitest-changed-or-full.mjs`) — em 1 processo com `PLAYWRIGHT_WORKERS=4`; 1 service `postgres-int` (localhost:5432) com **`pnpm db:reset`** (drop→migrate→seed:minimal) entre int e build/e2e (OPS88 — substitui o 2º container); fork-PR excluída no `if:` do job (repo público — não queima minutes) |
|
||||
| `deploy.yml` | **`push` em `main` (automático, OPS104) + `workflow_dispatch`** | 1× service Postgres 17 | **Cinco jobs (OPS103 + OPS104):** `preflight` (hosted; `scripts/deploy-preflight.mjs` pula o run se já há deploy `queued`/`in_progress` — `waiting` de approval não conta; dispatch manual pula o guard) → `verify` (hosted, suíte **full** sem skips — lint→format→typecheck→knip→cycles→unit→int→`db:reset`→build `.next-e2e`→e2e full; OPS88: um build só, o default saiu) → `deploy-staging` (`needs: [verify]`, `environment: staging`, `TEQO_ENV=staging`) → `requeue` (hosted, **fora** do grupo `deploy-homeserver`; `scripts/deploy-requeue.mjs` re-despacha `deploy.yml` se o head de `main` andou — OPS104) e `deploy-production` (`needs: [deploy-staging]`, `environment: production` com required reviewer, `TEQO_ENV=production`); ambos os deploys rodam em `runs-on: [self-hosted, homeserver]` e executam `scripts/deploy-homeserver.sh <sha>` **localmente** no homeserver (sem SSH; roda até o fim com o SHA do run mesmo se `main` andar durante o verify — OPS102; idempotente; um `flock` compartilhado + `concurrency: deploy-homeserver` com `queue: max`; migrator→migrate→runner→rollout→smoke) — staging vermelho fail-closa produção; nada publica sem o approve do reviewer |
|
||||
| `issue-done-on-main-merge.yml` | PR merged → `main` (same-repo) + **dispatch manual (recovery)** | — | `Closes`/`Fixes` → `done` + `in-prod` + **close** **no GitHub** (token `GITHUB_TOKEN`; body do PR via `PR_BODY` do evento GitHub — no dispatch, busca via `gh` com o mesmo contrato: merged + main + same-repo) |
|
||||
| `plan-issue-ready-on-main-merge.yml` | PR merged → `main` (same-repo) + **dispatch manual (recovery)** | — | `Related #N` aguardando plano → `ready` **no GitHub** (OPS18; soft-skip; mesmo token) |
|
||||
| `agent-pr-ready-automerge.yml` | PR same-repo → `main` (open/sync/…) | — | Safety net via `github-pr-automerge.mjs` (plain Node, REST+GraphQL): draft `cursor/*` → Ready (PATCH); arm `enablePullRequestAutoMerge` (rebase) **com o `AUTOMERGE_PAT`** (fail-closed — nunca o `GITHUB_TOKEN` nativo, cujo merge como `github-actions[bot]` não cria runs no `closed`; OPS71-FLIP) — o **servidor** espera o required check `CI (PR) / checks` (o OPS64 "rollup pode mentir" é estrutural aqui: job único posta um check-run honesto); draft não-`cursor/*` = veto, skip |
|
||||
| `archive-cursor-agent.yml` | dispatch manual (ops) | — | Helper: cancela run + arquiva um agente Cursor Cloud (`CURSOR_API_KEY`) — **dormente (OPS65)** como o pool |
|
||||
|
||||
`agent-pool.yml` foi **removido no OPS65** (pool dormente; o tick `*/10` custava 144 `pnpm install`/dia na workstation). O verificador de main (`ci.yml`) foi **eliminado no OPS71** (gate 2026-08-19): só o `verify` do dispatch manual roda a suíte full.
|
||||
`agent-pool.yml` foi **removido no OPS65** (pool dormente; o tick `*/10` custava 144 `pnpm install`/dia na workstation). O verificador de main (`ci.yml`) foi **eliminado no OPS71** (gate 2026-08-19): só o `verify` do `deploy.yml` (iniciado por push em `main` ou dispatch manual) roda a suíte full.
|
||||
|
||||
Action runtimes: `actions/checkout@v5`, `actions/setup-node@v5`, `pnpm/action-setup@v6` e `actions/cache@v4`.
|
||||
|
||||
|
|
@ -102,13 +102,13 @@ Local: `GITHUB_TOKEN` (PAT com escopo `repo` + `issues: write`) para `node scrip
|
|||
|
||||
### Flips pós-merge (OPS71-FLIP): o evento `closed` e a recuperação manual
|
||||
|
||||
O flip `issue-done`/`plan-issue-ready` roda no `pull_request: closed` do PR no GitHub — mas **o merge feito pelo auto-merge armado com o `GITHUB_TOKEN` nativo é atribuído a `github-actions[bot]` e NÃO cria workflow runs** (anti-recursão documentada do GitHub: eventos acionados por ações do `GITHUB_TOKEN` não geram runs; o `closed` fica no timeline do PR com 0 runs — achado ao vivo no PR #746). Isso só não acontece porque o arm agora usa o `AUTOMERGE_PAT` (merge atribuído a usuário real → runs criados, comportamento do PR #742).
|
||||
O flip `issue-done`/`plan-issue-ready` roda no `pull_request: closed` do PR no GitHub — mas **o merge feito pelo auto-merge armado com o `GITHUB_TOKEN` nativo é atribuído a `github-actions[bot]` e NÃO cria workflow runs** (anti-recursão documentada do GitHub: eventos acionados por ações do `GITHUB_TOKEN` não geram runs; o `closed` fica no timeline do PR com 0 runs — achado ao vivo no PR #746). Isso só não acontece porque o arm agora usa o `AUTOMERGE_PAT` (merge atribuído a usuário real → runs criados, comportamento do PR #742). Desde a OPS104 o mesmo invariante também governa o deploy automático: um merge via `GITHUB_TOKEN` não criaria o run de `push` do `deploy.yml` — o `AUTOMERGE_PAT` é o que mantém trigger 1 (e os flips) funcionando.
|
||||
|
||||
**Se mesmo assim o flip não rodou** (evento perdido, run falhou, PR mergeado por bot antes do fix): o workflow tem **`workflow_dispatch` de recuperação** — dispatch de `issue-done-on-main-merge.yml` (ou `plan-issue-ready-on-main-merge.yml`) com o input `pr` = número do PR; o job busca o PR via `gh` e aplica o mesmo contrato do evento (merged + base main + same-repo, senão skip). O flip **fecha** a Issue (`PATCH state=closed` — o `closed` do GitHub por keyword pode fechar, mas o flip explicita labels `done`/`in-prod` + close para o tracker GitHub). Falha em qualquer passo = job vermelho (OPS61, nunca mentir).
|
||||
|
||||
Branch protection de `main` no **GitHub** (aplicada no cutover do OPS71, via `pnpm configure:branch-protection`): required check `CI (PR) / checks` (a UI exibe workflow/job; o **literal de match é `checks`** — o GitHub casa pelo nome do check-run — pin ao vivo PR #742), `strict=false`, 0 reviews, `enforce_admins: true` (nem admin mergea com CI vermelho — mesmo invariante do rule-based Forgejo). O auto-merge nativo espera esse mesmo check antes de mergear; a regra no servidor é o gate real de qualquer caminho de merge, inclusive API manual. Reaplicar/consertar drift: `pnpm configure:branch-protection` (idempotente).
|
||||
|
||||
Deploy (OPS53 + OPS71 + OPS103): **manual** via `workflow_dispatch` no `deploy.yml` — o job `verify` (hosted, suíte full incl. e2e full, **intocado** — verifica o commit uma vez) roda **antes** dos deploys: `deploy-staging` (`environment: staging`, sem reviewer) e então `deploy-production` (`needs: [deploy-staging]`, `environment: production` com required reviewer; staging vermelho nunca deixa produção rodar). Os dois executam `scripts/deploy-homeserver.sh <sha>` no runner self-hosted do GitHub **no homeserver** (conecta outbound ao GitHub — funciona atrás do Cloudflare tunnel; o hosted nunca toca o homeserver; o self-hosted não conta minutos hosted), parametrizado por `TEQO_ENV` (`production` default intocado; `staging` = `teqo-staging`/`teqo_staging`/`~/stack/teqo-staging.env`/`127.0.0.1:1314`). O script: flock por ambiente → guard "already deployed" (revision do container do ambiente) → build do migrator (BuildKit, secrets do env file do ambiente) → push/tag em `localhost:5000` → swap dos tags no compose (backup; sed ancorado ao serviço do ambiente) → **migrations** (`teqo-1313-migrate`/`teqo-staging-migrate` — antes do build do runner, OPS66) → build do runner (contra o banco JÁ migrado) → rollout → healthcheck → smoke. Desde a OPS102 o deploy roda até o fim com o SHA do dispatch mesmo se `main` avançar durante o verify (o antigo guard de stale run era da era automática); o único verde sem build é o "already deployed", que prova que o container já roda o SHA. Falha = job vermelho; nada é publicado pela metade. Runbook (rollback, falhas conhecidas, bootstrap do staging): `docs/ops/teqo-1313-deploy.md`. O deploy Vercel foi removido no OPS50; o verificador automático de main foi eliminado no OPS71 — se um dia o merge em main quiser rede de regressão, o `ci.yml` volta com 1 linha de trigger (sem deploy).
|
||||
Deploy (OPS53 + OPS71 + OPS103 + OPS104): **começa automático** — um `push` em `main` dispara o `deploy.yml` (o job `preflight` pula se já há deploy `queued`/`in_progress`; um run `waiting` de approval **não** conta) e `workflow_dispatch` segue como escape manual. O job `verify` (hosted, suíte full incl. e2e full, **intocado** — verifica o commit uma vez) roda **antes** dos deploys: `deploy-staging` (`environment: staging`, sem reviewer) e então `deploy-production` (`needs: [deploy-staging]`, `environment: production` com required reviewer; staging vermelho nunca deixa produção rodar). Depois do staging verde, o job hosted `requeue` compara o head de `main` com o SHA do run e re-despacha um run novo se `main` andou no meio — **fora** do grupo de concurrency, para um approval pendente não bloquear a convergência. Os dois deploys executam `scripts/deploy-homeserver.sh <sha>` no runner self-hosted do GitHub **no homeserver** (conecta outbound ao GitHub — funciona atrás do Cloudflare tunnel; o hosted nunca toca o homeserver; o self-hosted não conta minutos hosted), parametrizado por `TEQO_ENV` (`production` default intocado; `staging` = `teqo-staging`/`teqo_staging`/`~/stack/teqo-staging.env`/`127.0.0.1:1314`). O script: flock por ambiente → guard "already deployed" (revision do container do ambiente) → build do migrator (BuildKit, secrets do env file do ambiente) → push/tag em `localhost:5000` → swap dos tags no compose (backup; sed ancorado ao serviço do ambiente) → **migrations** (`teqo-1313-migrate`/`teqo-staging-migrate` — antes do build do runner, OPS66) → build do runner (contra o banco JÁ migrado) → rollout → healthcheck → smoke. Desde a OPS102 o deploy roda até o fim com o SHA do run mesmo se `main` avançar durante o verify; a convergência é do `requeue` da OPS104 (o antigo guard de stale run segue morto); o único verde sem build é o "already deployed", que prova que o container já roda o SHA. Falha = job vermelho; nada é publicado pela metade. Runbook (rollback, falhas conhecidas, bootstrap do staging): `docs/ops/teqo-1313-deploy.md`. O deploy Vercel foi removido no OPS50; o verificador automático de main (`ci.yml`) foi eliminado no OPS71 — a rede de regressão de `main` agora é o `verify` full do próprio `deploy.yml`, que nasce no merge (OPS104 supersede o anti-goal "manual-only" da OPS102: automatiza-se o **início** do run, nunca o approve de produção).
|
||||
|
||||
## Cursor Cloud
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ Three layers, all local-database-only (see `local-database` skill; tests refuse
|
|||
|
||||
**Level preference (pyramid):** unit first (pure logic) → integration for Payload/DB boundaries → e2e only when the flow has real benefit the lower layers don't cover (critical user path, UX/navigation, URL contract). Canonical definition: `test-driven-development` skill.
|
||||
|
||||
CI (`ci-pr.yml`) is a **single sequential job `checks`** (OPS62 X1): guards → lint (zero warnings) → format → typecheck → knip → cycles → unit/int (**affected by the PR diff**: `vitest --changed` against the PR base via `scripts/test-affected.mjs`, full-suite fallback on high-risk paths) → build → e2e (manifest path→spec via `scripts/e2e-affected.mjs` + `scripts/lib/e2e-affected-manifest.mjs`, blocking; **only in mode `selected` — OPS72: the PR never runs full e2e**; production build + `list`/`github` reporters in CI) — against ONE Postgres 17 service, with a deterministic **`pnpm db:reset`** (drop schema → migrate → seed:minimal) between the int phase and the build/e2e phase (OPS88). Fail-fast is structural: a failed step stops the run; e2e runs with `PLAYWRIGHT_WORKERS=4`. There is no main verifier (`ci.yml` eliminated in OPS71): the full net including e2e runs in the deploy `verify` job before publishing (manual dispatch).
|
||||
CI (`ci-pr.yml`) is a **single sequential job `checks`** (OPS62 X1): guards → lint (zero warnings) → format → typecheck → knip → cycles → unit/int (**affected by the PR diff**: `vitest --changed` against the PR base via `scripts/test-affected.mjs`, full-suite fallback on high-risk paths) → build → e2e (manifest path→spec via `scripts/e2e-affected.mjs` + `scripts/lib/e2e-affected-manifest.mjs`, blocking; **only in mode `selected` — OPS72: the PR never runs full e2e**; production build + `list`/`github` reporters in CI) — against ONE Postgres 17 service, with a deterministic **`pnpm db:reset`** (drop schema → migrate → seed:minimal) between the int phase and the build/e2e phase (OPS88). Fail-fast is structural: a failed step stops the run; e2e runs with `PLAYWRIGHT_WORKERS=4`. There is no main verifier (`ci.yml` eliminated in OPS71): the full net including e2e runs in the deploy `verify` job before publishing — the deploy run now starts on merge to `main` (OPS104), and production still waits for the `production` environment approval.
|
||||
|
||||
**Local e2e (OPS72 — skill step, discretionary):** before push, run the e2e specs you created + the same affected surface — `pnpm test:e2e:affected` runs `e2e-affected.mjs` against `origin/main`, migrates/seeds, then `pnpm test:e2e` with the selected specs (full suite on high-risk diffs). High-risk diffs diverge from PR CI by design: the PR runs no e2e (selected only, never full) and the local mirror runs the full suite. Forward extra Playwright args after `--` for a narrowed repro. Opt into production mode with `E2E_PROD=1 pnpm test:e2e:affected` (CI and prod mode serve the production build via `pnpm start`; plain local runs use `pnpm dev` — dev-mode flakes like cold-compile timeouts and dev-only console warnings do not reproduce in prod mode).
|
||||
|
||||
|
|
|
|||
1
docs/changelog/2026-09-14-ops104.md
Normal file
1
docs/changelog/2026-09-14-ops104.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
**OPS104 — Disparo automático do deploy pós-merge (staging automático, produção com review) (2026-09-14):** publicar deixou de depender de alguém lembrar do dispatch — o `deploy.yml` agora nasce no merge e converge sozinho, sem nunca auto-aprovar produção. **Trigger 1:** `push` em `main` somado ao `workflow_dispatch` (escape manual preservado); o job hosted `preflight` (`scripts/deploy-preflight.mjs`) pula o run quando já existe outro deploy `queued`/`in_progress` — um run `waiting` de approval **não** conta (merge nessa janela começa um run novo e os dois ficam pendentes para o humano escolher), e falha de API é fail-open (o guard é dedupe, não gate). **Trigger 2:** depois do `deploy-staging` verde, o job hosted `requeue` (`scripts/deploy-requeue.mjs`, **fora** do grupo `deploy-homeserver` — um approval pendente não pode bloquear a convergência) lê o head de `main` e re-despacha o workflow se um merge chegou durante o verify/staging; falha é fail-red. A lógica de decisão é pura (`scripts/lib/deploy-trigger.mjs`; `ACTIVE_RUN_STATUSES`, `decidePreflight`, `decideRequeue`) e o client `scripts/lib/github-api.mjs` ganhou `listWorkflowRuns`/`getBranchHead`; dispatch usa o `GITHUB_TOKEN` nativo (`workflow_dispatch` é a exceção documentada da anti-recursão — nenhum secret novo, nenhum PAT). O fluxo interno do deploy não mudou: `verify` full intocado (nada publica sem verde), environments intocados (`staging` sem reviewer, `production` com required reviewer), `concurrency: deploy-homeserver` (`cancel-in-progress: false`, `queue: max`) segue só nos jobs de deploy, nenhum auto-retry/cancelamento de runs antigos, nenhum workflow gêmeo e `scripts/deploy-homeserver.sh` intocado (pin `deployScript.unit.spec.ts` verde sem edição). O pin unitário do manual-only virou o pin do novo contrato (`ciSkipInvariants.unit.spec.ts`: push em `main` + guard + dispatch + requeue), com `deployTrigger.unit.spec.ts` novo e casos novos em `githubApi.unit.spec.ts`. Riscos operacionais documentados no runbook (`docs/ops/teqo-1313-deploy.md`): um approval pendente segura o grupo de concurrency (staging de runs novos espera) e um merge durante `deploy-production in_progress` só converge no próximo merge/dispatch — ambos com gatilho de revisitação. O anti-goal "manual-only" da OPS102 fica superseded: automatiza-se o **início** do run, nunca o approve de produção. Docs vivos realinhados (`AGENTS.md`, `AGENTS-infra.md`, `docs/AGENT-OPS.md`, `README.md`, `.agents/rules/agent-pr-workflow.mdc`, `.agents/rules/engineering-standards.mdc`, skill `bug-fix`, `docs/TESTING.md`).
|
||||
|
|
@ -1,31 +1,48 @@
|
|||
# Runbook: deploy do site 1313 no homeserver (OPS53 + OPS71 + OPS103 — dispatch manual)
|
||||
# Runbook: deploy do site 1313 no homeserver (OPS53 + OPS71 + OPS103 + OPS104 — início automático, produção com review)
|
||||
|
||||
O deploy é uma **action manual** (`workflow_dispatch` no GitHub Actions,
|
||||
`.github/workflows/deploy.yml`): mergir em `main` **não** publica nada. O
|
||||
operador dispara o deploy quando decide publicar; antes de tocar o homeserver,
|
||||
o job `verify` (hosted) roda a suíte **full** (incl. e2e full) **uma vez** e
|
||||
gateia os dois alvos: `deploy-staging` → aprovação do environment `production`
|
||||
→ `deploy-production` (OPS103).
|
||||
Desde a OPS104 o deploy **começa sozinho**: todo merge em `main` (evento
|
||||
`push`) inicia o run do `deploy.yml` no head, e um segundo gatilho re-despacha
|
||||
um run novo se `main` andar durante o verify/staging. O que continua manual é
|
||||
a **publicação em produção**: o job `deploy-production` espera a aprovação do
|
||||
environment `production`. Antes de tocar o homeserver, o job `verify` (hosted)
|
||||
roda a suíte **full** (incl. e2e full) **uma vez** e gateia os dois alvos:
|
||||
`deploy-staging` → aprovação do environment `production` →
|
||||
`deploy-production` (OPS103).
|
||||
|
||||
## Gatilho e fluxo
|
||||
|
||||
1. GitHub → Actions → **Deploy (manual)** → Run workflow (ref: `main`).
|
||||
2. Job `verify` (hosted `ubuntu-latest`, ~50 min): suíte full sem skips —
|
||||
1. **Início automático (OPS104):** merge em `main` → o run nasce no head; o
|
||||
job `preflight` (hosted) pula o run quando já existe outro deploy
|
||||
`queued`/`in_progress`. Um run aguardando aprovação de produção (`waiting`)
|
||||
**não** conta: um merge nessa janela começa um run novo e os dois ficam
|
||||
pendentes (o humano aprova qual promover). `workflow_dispatch` (GitHub →
|
||||
Actions → **Deploy** → Run workflow, ref: `main`) segue como escape manual e
|
||||
**não** passa pelo guard.
|
||||
2. Job `preflight` (hosted, `scripts/deploy-preflight.mjs`): consulta os runs
|
||||
do próprio workflow; sem deploy ativo → segue. Falha de API é fail-open
|
||||
(o guard é dedupe, não gate de segurança).
|
||||
3. Job `verify` (hosted `ubuntu-latest`, ~50 min): suíte full sem skips —
|
||||
check-test-locations → lint → format → typecheck → knip → cycles → unit →
|
||||
int (migrate+seed nos services) → build → e2e full. **Intocado pela
|
||||
OPS103**: verifica o commit, não o alvo.
|
||||
3. Se `verify` verde, o job `deploy-staging` (`needs: [verify]`,
|
||||
int (migrate+seed nos services) → build → e2e full. **Intocado pelas
|
||||
OPS103/OPS104**: verifica o commit, não o alvo.
|
||||
4. Se `verify` verde, o job `deploy-staging` (`needs: [verify]`,
|
||||
`environment: staging`, sem reviewer) roda no runner self-hosted e executa
|
||||
`TEQO_ENV=staging bash scripts/deploy-homeserver.sh <sha>` — publica o SHA
|
||||
em `staging.jorgesolla1313.com.br` (container `teqo-staging`,
|
||||
`127.0.0.1:1314`).
|
||||
4. Com staging verde, o job `deploy-production` (`needs: [deploy-staging]`,
|
||||
5. Com staging verde, o job `deploy-production` (`needs: [deploy-staging]`,
|
||||
`environment: production`) **aguarda a aprovação do reviewer** e então
|
||||
executa `TEQO_ENV=production bash scripts/deploy-homeserver.sh <sha>` —
|
||||
publica o **mesmo SHA** em produção (`teqo-1313`, `127.0.0.1:1313`).
|
||||
Staging vermelho = produção nunca roda; a corrida entre os dois é
|
||||
serializada pela `concurrency: deploy-homeserver`.
|
||||
5. O script, no **homeserver**: `flock` único (compose, workspace e registry
|
||||
6. **Requeue (OPS104):** com o staging verde, o job hosted `requeue`
|
||||
(`scripts/deploy-requeue.mjs`, **fora** do grupo `deploy-homeserver` —
|
||||
um run aguardando aprovação não pode bloquear o requeue) compara o head de
|
||||
`main` com o SHA do run; se `main` andou durante o verify/staging, dispara
|
||||
um run novo no head novo. Falha aqui é fail-red (job vermelho): staging
|
||||
velho nunca fica silencioso.
|
||||
7. O script, no **homeserver**: `flock` único (compose, workspace e registry
|
||||
são compartilhados — um deploy por vez no host; o `concurrency` do
|
||||
workflow serializa os runs e o lock cobre invocação manual; desde a OPS102
|
||||
o run vai até o fim com o SHA do dispatch mesmo se `main` avançar durante o
|
||||
|
|
@ -47,15 +64,17 @@ gateia os dois alvos: `deploy-staging` → aprovação do environment `productio
|
|||
teqo-<env>` → healthcheck → smoke (`/`, `/campanha/login`, `/admin`,
|
||||
barreira 307, WebAuthn login-options, `api/revalidate` com o secret real do
|
||||
ambiente).
|
||||
6. Falha = job vermelho; nada é publicado pela metade (rollback automático
|
||||
8. Falha = job vermelho; nada é publicado pela metade (rollback automático
|
||||
após o swap, **só do ambiente que falhou**).
|
||||
|
||||
**Primeiro deploy (verificação ao vivo):** depois do cutover OPS71, dispare o
|
||||
deploy manual e confira no log do job `deploy-production` o `set-url` do
|
||||
workspace (origem Forgejo → GitHub), o `deployed_sha` lido do container e o
|
||||
guard "already deployed" num segundo dispatch do mesmo SHA. Na OPS103, o
|
||||
caminho de estreia é o mesmo dispatch: staging primeiro (bootstrap abaixo),
|
||||
inspeção humana, aprovação, produção.
|
||||
**Primeiro deploy (verificação ao vivo):** o caminho de estreia do OPS104 é
|
||||
mergear em `main` e observar o run nascer sozinho: log do `preflight`
|
||||
(`should_deploy=true`), `verify` verde, staging publicado e `deploy-production`
|
||||
aguardando aprovação. Para o cutover OPS71, confira no log do job
|
||||
`deploy-production` o `set-url` do workspace (origem Forgejo → GitHub), o
|
||||
`deployed_sha` lido do container e o guard "already deployed" num segundo run
|
||||
do mesmo SHA. Na OPS103, staging primeiro (bootstrap abaixo), inspeção humana,
|
||||
aprovação, produção.
|
||||
|
||||
## Onde roda cada coisa
|
||||
|
||||
|
|
@ -75,7 +94,7 @@ secrets de integração: `FORGEJO_API_TOKEN` (flips pós-merge) e
|
|||
## Staging (OPS103)
|
||||
|
||||
Alvo real e descartável para validar o SHA (migração/build/rollout/smoke) antes
|
||||
de produção. Mesmo dispatch, mesmo `verify`, mesmo script — o que muda é
|
||||
de produção. Mesmo run, mesmo `verify`, mesmo script — o que muda é
|
||||
`TEQO_ENV=staging` e o environment `staging` (sem reviewer; a aprovação que
|
||||
importa é a de `production`).
|
||||
|
||||
|
|
@ -222,8 +241,10 @@ a correção se re-mergeia e o próximo deploy completo publica.
|
|||
|
||||
| Sintoma | Causa | Tratamento |
|
||||
| ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Prod regride para um SHA antigo após re-dispatch fora de ordem | O `flock` serializa mas não ordena; o "already deployed" não pega revision diferente | Não re-dispatchar SHA antigo; se acontecer, re-dispatchar o `main` atual (rollback manual disponível na seção Rollback). Revisitar se o deploy ganhar frequência/automação ou se uma regressão fora-de-ordem acontecer |
|
||||
| Job verde sem deploy ("already deployed") | O container rodando já tem a revision do SHA do job (ex.: `workflow_dispatch` duplicado da mesma HEAD) | Nada a fazer — o site já roda esse SHA. Forçar rebuild da MESMA HEAD não é suportado via dispatch: edite o tag da imagem no compose (`image: localhost:5000/teqo-1313:<sha>-rebuild`) e `docker compose up -d`, ou espere um commit novo |
|
||||
| Prod regride para um SHA antigo após re-dispatch fora de ordem | O `flock` serializa mas não ordena; o "already deployed" não pega revision diferente | Evitar re-dispatchar SHA antigo (o requeue da OPS104 sempre despacha o head atual); se acontecer, re-dispatchar o `main` atual (rollback manual disponível na seção Rollback). Revisitar se uma regressão fora-de-ordem acontecer mesmo com o requeue |
|
||||
| Job verde sem deploy ("already deployed") | O container rodando já tem a revision do SHA do job (ex.: run duplicado da mesma HEAD — `workflow_dispatch`, ou push + requeue da OPS104) | Nada a fazer — o site já roda esse SHA. Forçar rebuild da MESMA HEAD não é suportado via dispatch: edite o tag da imagem no compose (`image: localhost:5000/teqo-1313:<sha>-rebuild`) e `docker compose up -d`, ou espere um commit novo |
|
||||
| Merge durante aprovação pendente de produção: staging do run novo fica `pending` | O job `waiting` de approval **segura o grupo** `deploy-homeserver` (a avaliação de concurrency acontece antes do approval — comportamento do GitHub) | O run novo já nasceu (o guard não conta `waiting`); aprovar/rejeitar o run pendente libera o grupo. Nada publica sem reviewer. Revisitar se aprovações passarem a demorar sistematicamente → avaliar grupos/locks por ambiente (o lock compartilhado é decisão da OPS103) |
|
||||
| Merge durante `deploy-production` `in_progress` não gera requeue | O push nasce, o `preflight` vê o deploy ativo e pula; o requeue daquele run já rodou (logo após o staging) | Nada nasce até o próximo merge ou `workflow_dispatch`. Revisitar na primeira ocorrência real → 2º requeue após produção verde, ou refinar o guard |
|
||||
| Job de deploy nunca roda / runner offline | Runner self-hosted do homeserver não instalado ou parado (passo manual do cutover OPS71) | Instalar/religar o runner (labels `self-hosted`, `homeserver`); os PRs e o `verify` não dependem dele — só os deploys |
|
||||
| Deploy dispara mas o job `verify` falha | Regressão real na suíte full | Corrigir e re-dispatchar — o homeserver nunca é tocado |
|
||||
| `No such image` ao recriar o container fora de deploy (ex.: `compose up -d` pós-reboot/migração) | O compose referenciava uma tag local bare (`teqo-1313:<sha>`) que desapareceu sem recriação (achado INF8/INF13, 24/08: só restava a ref do registry) | Corrigido no INF13: o compose passa a referenciar a tag **qualificada** (`localhost:5000/teqo-1313:<sha>`) a partir do primeiro deploy pós-fix — até lá, produção ainda roda o compose bare (recrie o alias via `docker tag` se necessário). Recovery geral: `docker pull localhost:5000/teqo-1313:<sha>` (+ `docker tag` se o compose em uso tiver tag bare) e `docker compose up -d` |
|
||||
|
|
@ -261,12 +282,14 @@ automaticamente — **não serve push, não recebe PRs, é somente leitura**.
|
|||
|
||||
- Os jobs `deploy-staging`/`deploy-production` usam
|
||||
`runs-on: [self-hosted, homeserver]` — executam como o usuário do runner no
|
||||
homeserver. Estão **apenas** no `deploy.yml` (dispatch manual), nunca no
|
||||
`ci-pr.yml`; o hosted (`verify`) não tem acesso ao homeserver e o self-hosted
|
||||
não recebe secrets de produção (as envs vivem no próprio homeserver,
|
||||
inclusive as de staging — nunca como GitHub secrets). `main` só anda por PR
|
||||
mergeado com CI verde; o deploy só roda com `verify` full verde. Fork-PRs
|
||||
não rodam CI (same-repo gate + setting do repo).
|
||||
homeserver. Estão **apenas** no `deploy.yml` (início automático por `push`
|
||||
em `main` ou `workflow_dispatch`; ambos passam pelo mesmo `verify` full),
|
||||
nunca no `ci-pr.yml`; o hosted (`preflight`/`verify`/`requeue`) não tem
|
||||
acesso ao homeserver e o self-hosted não recebe secrets de produção (as
|
||||
envs vivem no próprio homeserver, inclusive as de staging — nunca como
|
||||
GitHub secrets). `main` só anda por PR mergeado com CI verde; o deploy só
|
||||
roda com `verify` full verde. Fork-PRs não rodam CI (same-repo gate +
|
||||
setting do repo).
|
||||
- Segredos nunca ecoados: sem `set -x`, senhas via `--password-stdin` /
|
||||
build-secrets; envs só no homeserver.
|
||||
- Staging sem PII real (seed sintético), bucket/DB/env próprios e noindex —
|
||||
|
|
@ -286,9 +309,10 @@ fallback e não foi removida por esta entrega.
|
|||
"Testing" o refresh token expira em ~7 dias e o estado vira `erro` na UI.
|
||||
3. Adicione ao `~/stack/teqo-1313.env` (homeserver, chmod 600):
|
||||
`GOOGLE_CALENDAR_OAUTH_CLIENT_ID` e `GOOGLE_CALENDAR_OAUTH_CLIENT_SECRET`.
|
||||
4. Deploy normal (`workflow_dispatch` do `deploy.yml`). O callback monta o
|
||||
redirect a partir de `NEXT_PUBLIC_SITE_URL` — precisa bater exatamente com
|
||||
o URI registrado no passo 1.
|
||||
4. Deploy normal (`deploy.yml` — o merge em `main` já dispara; `workflow_dispatch`
|
||||
segue como escape). O callback monta o redirect a partir de
|
||||
`NEXT_PUBLIC_SITE_URL` — precisa bater exatamente com o URI registrado no
|
||||
passo 1.
|
||||
|
||||
Desconectar no Teqo apaga o refresh token daquele lado; para revogar o acesso
|
||||
de fato, revogue o app em `myaccount.google.com/permissions` (a UI orienta o
|
||||
|
|
@ -502,8 +526,9 @@ Para desfazer o acervo inteiro: `DELETE FROM "speech_segment"; DELETE FROM "spee
|
|||
## Referências
|
||||
|
||||
- `scripts/deploy-homeserver.sh` — o script (fonte da verdade do fluxo; parametrizado por `TEQO_ENV`)
|
||||
- `.github/workflows/deploy.yml` — verify → deploy-staging → deploy-production (OPS103)
|
||||
- `.github/workflows/deploy.yml` — preflight → verify → deploy-staging → requeue/deploy-production (OPS103/OPS104)
|
||||
- `docs/plans/ops53-ci-deploy-homeserver*.md` — intenção e decisões (era Forgejo)
|
||||
- `docs/plans/ops71-ci-github-actions-tracker-forgejo*.md` — o cutover
|
||||
- `docs/plans/ops103-staging-homeserver*.md` — staging com verify único e aprovações separadas
|
||||
- `docs/plans/ops104-disparo-automatico-do-deploy*.md` — início automático + requeue (staging automático, produção com review)
|
||||
- infra-solla: `STATE.md`, `plano-infra-final.md` §"Arquitetura de deploy"
|
||||
|
|
|
|||
166
docs/plans/ops104-disparo-automatico-do-deploy-impl.md
Normal file
166
docs/plans/ops104-disparo-automatico-do-deploy-impl.md
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
# 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 `main` sem deploy ativo inicia sozinho o `deploy.yml` no head; `verify` full → `deploy-staging` automático; ao terminar o staging, se `main` andou, um run novo nasce no head novo; produção continua **somente** com aprovação humana do environment `production`. Dispatch manual segue como escape.
|
||||
- **O que NÃO negociar:** nunca auto-aprovar/publicar produção; nunca atalhar o `verify` full; nunca um workflow/script gêmeo (o dono é o `deploy.yml`); nunca tocar `ci-pr.yml`, o job `verify`, os guards de banco local, secrets ou o `scripts/deploy-homeserver.sh` (pinado por `deployScript.unit.spec.ts`); nunca reintroduzir o stale-run guard (OPS102); `concurrency: deploy-homeserver` intocado (`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.ts` l.113-114 (lê YAML cru): confirmado — `toContain('workflow_dispatch:')` e `not.toMatch(/^\s*push:/m)` quebram ao adicionar `push`; o teste l.124-150 é additive-safe (jobs/chaves novas passam; o literal `needs: [deploy-staging]` não pode mudar). O pin do manual-only será reescrito nesta entrega.
|
||||
- `deployScript.unit.spec.ts` não muda (o script é congelado; `git ls-remote` segue 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, o `deploy-staging` de um run novo fica `pending` atrás dela. O aceite de produto continua satisfeito (o run novo **nasce**; `waiting` nã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 `push` que o preflight skipou termina `completed` (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
|
||||
|
||||
```mermaid
|
||||
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/runs` via helper node em `scripts/lib/github-api.mjs`; B) `gh run list` inline no YAML; C) `concurrency` puro (`queue: max`) sem guard; D) checar check-runs/status do commit.
|
||||
- **Recomendação:** **A** — dois `GET` com `status=queued` e `status=in_progress`, `branch=main`, `per_page=100`; filtra `run.id != github.run_id` e decide. Usa o client existente (retry OPS67 para GET/5xx), testável com `fetchImpl`, sem dependência de CLI.
|
||||
- **Rejeitadas:** B porque parseia texto de `gh` (o repo tirou `gh` dos 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 run `queued` não tem check-run no commit e um job `waiting` de aprovação (que **não** conta) é indistinguível de `in_progress` por check-run.
|
||||
|
||||
#### D2 — Como detectar o head novo de `main` (trigger 2)
|
||||
|
||||
- **Opções:** A) REST `GET /repos/{owner}/{repo}/git/ref/heads/main` via `getBranchHead`; 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 em `scripts/lib/deploy-trigger.mjs` (`decidePreflight`, `decideRequeue`) + dois CLIs finos (`scripts/deploy-preflight.mjs`, `scripts/deploy-requeue.mjs`); B) tudo inline no YAML com `curl`+`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.mjs` exporta `decideAutomergeAction` e o CLI `scripts/github-pr-automerge.mjs` só faz o wiring; o client `github-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 `jq` vira 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 deployed` verde 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 `preflight` roda nos dois eventos; o step do guard tem `if: github.event_name == 'push'` e o output do job cai em `'true'` por default (`${{ steps.guard.outputs.should_deploy || 'true' }}`); `verify` usa `needs: [preflight]` + `if: needs.preflight.outputs.should_deploy == 'true'`. B) job `preflight` com `if: github.event_name == 'push'` e consumidores com `always()`.
|
||||
- **Recomendação:** **A** — o `success()` implícito que o GitHub injeta em jobs com `needs` faria o dispatch pular o `verify` se 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 `verify` redundante. 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 `main` velho sem sinal.
|
||||
|
||||
#### D7 — Requeue roda em qualquer evento (push e dispatch)
|
||||
|
||||
- **Recomendação:** o job de requeue roda sempre que `deploy-staging` termina verde (push do trigger 1 **ou** dispatch manual, ref `main`). 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.yml`**
|
||||
- `name: Deploy` (realinha com o novo gatilho; hoje `Deploy (manual)`) e header reescrito (o comentário atual diz "workflow_dispatch only").
|
||||
- `on: push: branches: [main]` **somado** a `workflow_dispatch:` (mantido).
|
||||
- Novo job `preflight` (hosted, `timeout-minutes: 5`, `permissions: { contents: read, actions: read }`, `GITHUB_TOKEN: ${{ github.token }}`, checkout), com `outputs.should_deploy: ${{ steps.guard.outputs.should_deploy || 'true' }}` e o step do guard: `if: github.event_name == 'push'` → `node scripts/deploy-preflight.mjs`.
|
||||
- `verify`: ganha `needs: [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 grupo `deploy-homeserver` e **não** precisa de `deploy-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 no `scripts/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 }`) e `getBranchHead(branch = 'main')` → `{ ref, sha }` (REST `git/ref/heads/{branch}`). Reusa `request` (retry OPS67). Sem helper de cancelamento/listagem paginada — escopo estrito.
|
||||
- **`scripts/lib/deploy-trigger.mjs`** (novo, puro, espelhando `github-pr-flow.mjs`): `ACTIVE_RUN_STATUSES = {queued, in_progress}`; `decidePreflight({ eventName, currentRunId, runs })` → `{ shouldDeploy, reason, activeRunIds }` (dispatch → true; exclui `currentRunId`; só queued/in_progress de outros contam; `waiting`/`requested`/`completed` nã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`; dois `listWorkflowRuns('deploy.yml', { status, branch: 'main' })`; chama `decidePreflight`; escreve `should_deploy=<bool>` em `$GITHUB_OUTPUT`; em erro de API → warning + `true` (D6).
|
||||
- **`scripts/deploy-requeue.mjs`** (novo CLI): lê `GITHUB_SHA` e `GITHUB_TOKEN`; `getBranchHead('main')`; `decideRequeue`; se `dispatch` → `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; `waiting` e `completed` não; o run atual é excluído; `decideRequeue` compara SHAs.
|
||||
- **`tests/unit/githubApi.unit.spec.ts`**: casos para `listWorkflowRuns` (URL/params/normalização) e `getBranchHead` (URL/normalização/404→null).
|
||||
- **`tests/unit/ciSkipInvariants.unit.spec.ts`**: reescrever o pin l.111-122 ("manual-only") para "push em `main` + guard de run ativo + `workflow_dispatch` mantido"; manter l.124-150 (chain OPS103) e o pin do `ci-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
|
||||
|
||||
1. **Tracer lógico (unit-first, sem YAML)** — `scripts/lib/deploy-trigger.mjs` + `listWorkflowRuns`/`getBranchHead` em `github-api.mjs` + `tests/unit/deployTrigger.unit.spec.ts` + casos novos em `githubApi.unit.spec.ts`. Gate: `pnpm test:unit` verde. _~2–3h._
|
||||
2. **Trigger 1 (push + preflight)** — `scripts/deploy-preflight.mjs`; YAML: `on.push.branches[main]`, job `preflight` (com `|| 'true'` no output e guard push-only), `verify` com `needs`/`if`; reescrever o pin do `ciSkipInvariants` (com a mesma filtragem de comentários do teste do chain, para prosa do header nunca satisfazer o pin). Gate: `pnpm test:unit` verde. _~2h._
|
||||
3. **Trigger 2 (requeue)** — `scripts/deploy-requeue.mjs` + job `requeue` (needs staging, `actions: write`, fora do grupo). Gate: `pnpm test:unit` verde. _~1–2h._
|
||||
4. **Docs + changelog** — checklist da seção "Docs a realinhar" + `docs/changelog/2026-09-14-ops104.md` (uma entrada, additions-only). _~1,5h._
|
||||
5. **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 caminho `push`, pós-merge): (a) o merge deste PR dogfooda o trigger 1 — conferir run `Deploy` nasceu sozinho, `preflight` logou `should_deploy=true`, `verify` verde, staging publicado, produção aguardando; (b) mergear um follow-up trivial durante o `verify`/staging → conferir que o segundo run ou skipa no preflight ou o `requeue` do run ativo dispara o run novo no head; (c) confirmar que produção só sai com approve e que `deploy-homeserver.sh`/`deployScript` seguem intocados. _~1h + observação._
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- Auto-approve de `production`, auto-retry de `verify` vermelho, cancelamento automático de runs antigos (anti-goals do produto).
|
||||
- Workflow gêmeo/dispatcher separado; mover `verify` para self-hosted; mexer em `ci-pr.yml`, nos guards de banco, em secrets ou na proteção de branch.
|
||||
- Lock por ambiente no `deploy-homeserver.sh` ou 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_TOKEN` não cria run de `push` (anti-recursão).** O auto-merge já arma com `AUTOMERGE_PAT` (fail-closed; OPS71-FLIP) → push cria run. _Mitigação:_ invariante existente no `github-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 por `pull_request: closed` (só com evidência).
|
||||
- **R2 — Job `waiting` de approval segura o grupo `deploy-homeserver`** (comportamento do GitHub, discussion #67607): o `deploy-staging` de runs novos fica `pending` enquanto 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-production` `in_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ós `deploy-production` verde 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 deployed` verde) e deploy serializado pelo grupo.
|
||||
- **R5 — YAML do `deploy.yml` invá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 `verify` por merge/janela). _Mitigação:_ preflight pula quando há deploy ativo; requeue só dispara se o head mudou; cortes de escopo mantêm o `verify` intocado.
|
||||
- **R7 — `actions: write` do token nativo** no dispatch: o repo já tem jobs com `issues: write`/`contents: write` funcionando (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:** `push` em `main` nã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 do `deploy.yml`), l.88, l.105, l.111 (parágrafo do deploy).
|
||||
- `AGENTS.md` — l.15 ("publishing is a manual `workflow_dispatch`").
|
||||
- `AGENTS-infra.md` — l.5 ("publishing is a manual `workflow_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.mdc` l.14; `.agents/skills/bug-fix/SKILL.md` l.54, l.109; `docs/TESTING.md` l.13; `.agents/skills/work-issue/execution-pipeline.md` l.43.
|
||||
- `deploy.yml` — header e `name`.
|
||||
- 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 (`waiting` não conta no guard), ambos pendentes, approve humano decide; (4) nada publica sem `verify` full verde; vermelho = humano resolve (sem retry).
|
||||
- [ ] Invariantes AGENTS/engineering-standards: sem migration/UI/access/Consent; sem secret novo; `deploy-homeserver.sh` intocado (`deployScript.unit.spec.ts` verde sem edição); `verify`/`ci-pr.yml`/guards intocados; produção fail-closed no reviewer; `concurrency: deploy-homeserver` intocado e só nos deploys; anti-goal OPS102 (stale-run guard) não reintroduzido.
|
||||
- [ ] Testes: `tests/unit/deployTrigger.unit.spec.ts` (novo) e casos de `listWorkflowRuns`/`getBranchHead` no `githubApi.unit.spec.ts` verdes; pin do `ciSkipInvariants.unit.spec.ts` reescrito para "push em main + guard + dispatch" (com comments stripped) e chain OPS103 preservado; `deployScript.unit.spec.ts` sem diff.
|
||||
- [ ] `pnpm gate:fast` e `pnpm test:unit` verdes; 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 |
|
||||
61
scripts/deploy-preflight.mjs
Normal file
61
scripts/deploy-preflight.mjs
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
/**
|
||||
* Plain-Node CLI (no pnpm) — OPS104 trigger 1 guard. `deploy.yml` starts on
|
||||
* every `push` to `main`; this preflight skips the run when another deploy
|
||||
* run is already `queued`/`in_progress` (a run `waiting` on the production
|
||||
* approval does NOT count — the human gate must not silence a new merge).
|
||||
* Manual dispatches never reach the guard: the workflow step is push-only.
|
||||
*
|
||||
* Fail-open (OPS104 D6): the guard is a dedupe, not a safety gate — an API
|
||||
* blip must not silently disable the automatic deploy. Worst case is a
|
||||
* redundant `verify` run.
|
||||
*
|
||||
* Writes `should_deploy=<bool>` to `$GITHUB_OUTPUT`.
|
||||
*
|
||||
* node scripts/deploy-preflight.mjs
|
||||
*/
|
||||
|
||||
import { appendFileSync } from 'node:fs'
|
||||
|
||||
import { ACTIVE_RUN_STATUSES, decidePreflight } from './lib/deploy-trigger.mjs'
|
||||
import { createApi } from './lib/github-api.mjs'
|
||||
|
||||
const writeOutput = (name, value) => {
|
||||
const line = `${name}=${value}`
|
||||
if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, `${line}\n`)
|
||||
else console.log(`[deploy-preflight] ${line}`)
|
||||
}
|
||||
|
||||
const main = async () => {
|
||||
const eventName = process.env.GITHUB_EVENT_NAME
|
||||
if (eventName !== 'push') {
|
||||
console.log('[deploy-preflight] dispatch manual — should_deploy=true')
|
||||
writeOutput('should_deploy', 'true')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const api = createApi({})
|
||||
const runs = []
|
||||
for (const status of ACTIVE_RUN_STATUSES) {
|
||||
runs.push(...(await api.listWorkflowRuns('deploy.yml', { status, branch: 'main' })))
|
||||
}
|
||||
const verdict = decidePreflight({
|
||||
eventName,
|
||||
currentRunId: process.env.GITHUB_RUN_ID,
|
||||
runs,
|
||||
})
|
||||
const active =
|
||||
verdict.activeRunIds.length > 0 ? ` (ativos: ${verdict.activeRunIds.join(', ')})` : ''
|
||||
console.log(
|
||||
`[deploy-preflight] ${verdict.reason} — should_deploy=${verdict.shouldDeploy}${active}`,
|
||||
)
|
||||
writeOutput('should_deploy', String(verdict.shouldDeploy))
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[deploy-preflight] falha ao consultar os runs (${error instanceof Error ? error.message : error}) — fail-open, should_deploy=true`,
|
||||
)
|
||||
writeOutput('should_deploy', 'true')
|
||||
}
|
||||
}
|
||||
|
||||
await main()
|
||||
41
scripts/deploy-requeue.mjs
Normal file
41
scripts/deploy-requeue.mjs
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
/**
|
||||
* Plain-Node CLI (no pnpm) — OPS104 trigger 2. Runs hosted after a green
|
||||
* `deploy-staging`: if `main` advanced while the run was in flight, dispatch
|
||||
* a new `deploy.yml` from the new head. `workflow_dispatch` is the documented
|
||||
* anti-recursion exception, so the built-in `GITHUB_TOKEN` (`actions: write`)
|
||||
* suffices — no PAT.
|
||||
*
|
||||
* Fail-red (OPS104 D6): a failure here would leave staging stale in silence —
|
||||
* exit 1 makes it visible (the operator re-dispatches by hand).
|
||||
*
|
||||
* node scripts/deploy-requeue.mjs
|
||||
*/
|
||||
|
||||
import { decideRequeue } from './lib/deploy-trigger.mjs'
|
||||
import { createApi } from './lib/github-api.mjs'
|
||||
|
||||
const main = async () => {
|
||||
const runSha = process.env.GITHUB_SHA
|
||||
const api = createApi({})
|
||||
const head = await api.getBranchHead('main')
|
||||
if (!head?.sha) {
|
||||
throw new Error('head de main não encontrado — requeue não verificado')
|
||||
}
|
||||
const verdict = decideRequeue({ runSha, headSha: head.sha })
|
||||
if (verdict.reason === 'sha-ausente') {
|
||||
throw new Error(`SHA do run ausente (GITHUB_SHA) — requeue não verificado`)
|
||||
}
|
||||
if (!verdict.dispatch) {
|
||||
console.log(`[deploy-requeue] ${verdict.reason} — sem dispatch`)
|
||||
return
|
||||
}
|
||||
await api.workflowDispatch('deploy.yml', { ref: 'main' })
|
||||
console.log(`[deploy-requeue] main avançou (${runSha} → ${head.sha}) — deploy.yml re-dispatchado`)
|
||||
}
|
||||
|
||||
try {
|
||||
await main()
|
||||
} catch (error) {
|
||||
console.error(`[deploy-requeue] falhou: ${error instanceof Error ? error.message : error}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
|
@ -71,7 +71,7 @@ for (const number of numbers) {
|
|||
await api.setLabels(number, { add: ['done', 'in-prod'], remove: ['in-progress'] })
|
||||
await api.addComment(
|
||||
number,
|
||||
`Merged em main via PR #${prNumber} — \`done\` + \`in-prod\` + Issue fechada. Deploy de produção é manual (workflow_dispatch no GitHub Actions) — ver docs/AGENT-OPS.md.`,
|
||||
`Merged em main via PR #${prNumber} — \`done\` + \`in-prod\` + Issue fechada. O deploy começa sozinho no merge (staging automático); produção publica só com o approve do reviewer no environment \`production\` — ver docs/AGENT-OPS.md.`,
|
||||
)
|
||||
// OPS76: o PR e a Issue vivem no GitHub; o close nativo por keyword
|
||||
// (`Closes #N`) só acontece para issues AINDA abertas citadas no body —
|
||||
|
|
|
|||
55
scripts/lib/deploy-trigger.mjs
Normal file
55
scripts/lib/deploy-trigger.mjs
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
/**
|
||||
* Pure decision logic for the automatic deploy trigger (OPS104), consumed by
|
||||
* `scripts/deploy-preflight.mjs` (trigger 1) and `scripts/deploy-requeue.mjs`
|
||||
* (trigger 2). Unit-tested in tests/unit/deployTrigger.unit.spec.ts.
|
||||
*
|
||||
* Product decisions locked in the OPS104 gate (2026-09-14):
|
||||
* - Only `queued`/`in_progress` runs count as an active deploy. A run
|
||||
* `waiting` on the production environment approval does NOT block: a merge
|
||||
* in that window starts a new run and both stay pending for the human.
|
||||
* - A manual `workflow_dispatch` is always the deliberate act — the guard is
|
||||
* push-only (the workflow skips the step on dispatch).
|
||||
* - Trigger 2 only dispatches when `main` really advanced; the same SHA
|
||||
* (idempotent re-dispatch) is a no-op.
|
||||
*/
|
||||
|
||||
/** Only these run statuses count as an active deploy in the trigger-1 guard. */
|
||||
export const ACTIVE_RUN_STATUSES = new Set(['queued', 'in_progress'])
|
||||
|
||||
/**
|
||||
* Trigger 1 guard: should this push start the deploy run?
|
||||
*
|
||||
* @param {{
|
||||
* eventName?: string,
|
||||
* currentRunId?: string | number | null,
|
||||
* runs?: Array<{ id: string | number, status?: string }>,
|
||||
* }} [input]
|
||||
* @returns {{ shouldDeploy: boolean, reason: 'dispatch-manual' | 'deploy-ativo' | 'sem-deploy-ativo', activeRunIds: Array<string | number> }}
|
||||
*/
|
||||
export const decidePreflight = ({ eventName, currentRunId, runs = [] } = {}) => {
|
||||
if (eventName !== 'push') {
|
||||
return { shouldDeploy: true, reason: 'dispatch-manual', activeRunIds: [] }
|
||||
}
|
||||
const activeRunIds = runs
|
||||
.filter((run) => ACTIVE_RUN_STATUSES.has(run?.status))
|
||||
.filter((run) => String(run.id) !== String(currentRunId))
|
||||
.map((run) => run.id)
|
||||
if (activeRunIds.length > 0) {
|
||||
return { shouldDeploy: false, reason: 'deploy-ativo', activeRunIds }
|
||||
}
|
||||
return { shouldDeploy: true, reason: 'sem-deploy-ativo', activeRunIds: [] }
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger 2 verdict: did `main` advance past the run's SHA?
|
||||
* A missing SHA yields `sha-ausente` — the CLI treats it as an error
|
||||
* (fail-red), never as a silent no-op.
|
||||
*
|
||||
* @param {{ runSha?: string | null, headSha?: string | null }} [input]
|
||||
* @returns {{ dispatch: boolean, reason: 'sha-ausente' | 'head-atual' | 'head-avancou' }}
|
||||
*/
|
||||
export const decideRequeue = ({ runSha, headSha } = {}) => {
|
||||
if (!runSha || !headSha) return { dispatch: false, reason: 'sha-ausente' }
|
||||
if (runSha === headSha) return { dispatch: false, reason: 'head-atual' }
|
||||
return { dispatch: true, reason: 'head-avancou' }
|
||||
}
|
||||
|
|
@ -311,6 +311,38 @@ export const createApi = ({
|
|||
body: { ref, inputs },
|
||||
}),
|
||||
|
||||
/**
|
||||
* GET /actions/workflows/{file}/runs — normalized, newest first. GitHub's
|
||||
* `status` filter accepts a single value, so callers wanting several poll
|
||||
* once per status (e.g. OPS104's queued + in_progress).
|
||||
* @param {string} workflowFile
|
||||
* @param {{ status?: string, branch?: string, limit?: number }} [options]
|
||||
*/
|
||||
listWorkflowRuns: async (workflowFile, { status, branch, limit = 100 } = {}) => {
|
||||
const payload = await request(
|
||||
`/repos/${owner}/${name}/actions/workflows/${workflowFile}/runs`,
|
||||
{ query: { status, branch, per_page: limit } },
|
||||
)
|
||||
const runs = Array.isArray(payload?.workflow_runs) ? payload.workflow_runs : []
|
||||
return runs.map((run) => ({
|
||||
id: run.id,
|
||||
status: run.status ?? '',
|
||||
conclusion: run.conclusion ?? null,
|
||||
event: run.event ?? '',
|
||||
headSha: run.head_sha ?? '',
|
||||
headBranch: run.head_branch ?? '',
|
||||
createdAt: run.created_at,
|
||||
htmlUrl: run.html_url ?? '',
|
||||
}))
|
||||
},
|
||||
|
||||
/** GET /git/ref/heads/{branch} — `{ ref, sha }`; `null` when the branch is gone (404). */
|
||||
getBranchHead: async (branch = 'main') => {
|
||||
const ref = await request(`/repos/${owner}/${name}/git/ref/heads/${branch}`)
|
||||
if (!ref) return null
|
||||
return { ref: ref.ref ?? '', sha: ref.object?.sha ?? '' }
|
||||
},
|
||||
|
||||
/** GET /contents/{path}?ref= — { content, sha } base64-decoded. */
|
||||
getFileContents: async (path, ref = 'main') => {
|
||||
const file = await request(`/repos/${owner}/${name}/contents/${path}`, { query: { ref } })
|
||||
|
|
|
|||
|
|
@ -28,6 +28,13 @@ import {
|
|||
|
||||
const repoRoot = join(fileURLToPath(new URL('.', import.meta.url)), '../..')
|
||||
|
||||
/** YAML with comment-only lines stripped — header prose must never satisfy a pin. */
|
||||
const readWorkflow = (relativePath: string) =>
|
||||
readFileSync(join(repoRoot, relativePath), 'utf8')
|
||||
.split('\n')
|
||||
.filter((line) => !line.trimStart().startsWith('#'))
|
||||
.join('\n')
|
||||
|
||||
describe('ciSkipInvariants', () => {
|
||||
it('exports shared path predicates used by classifiers and the lint guard', () => {
|
||||
expect(isSrcPath('src/lib/x.ts')).toBe(true)
|
||||
|
|
@ -108,15 +115,40 @@ describe('ciSkipInvariants', () => {
|
|||
}
|
||||
})
|
||||
|
||||
it('deploy is manual-only — never a push/schedule trigger (OPS71)', () => {
|
||||
const deploy = readFileSync(join(repoRoot, '.github/workflows/deploy.yml'), 'utf8')
|
||||
it('deploy starts on push to main with an active-run guard, dispatch kept (OPS104)', () => {
|
||||
// OPS104 supersedes the OPS71 manual-only pin: a push to main starts the
|
||||
// deploy run in the same workflow, and the preflight guard skips it when
|
||||
// another run is queued/in_progress (a `waiting` production approval does
|
||||
// NOT count — the script decision is pinned in deployTrigger.unit.spec.ts).
|
||||
// The manual dispatch stays as the escape. Comments are stripped first:
|
||||
// the header prose also mentions these keys and must never satisfy the pin.
|
||||
const deploy = readWorkflow('.github/workflows/deploy.yml')
|
||||
expect(deploy).toContain('workflow_dispatch:')
|
||||
expect(deploy).not.toMatch(/^\s*push:/m)
|
||||
expect(deploy).toContain(' push:')
|
||||
expect(deploy).toContain(' branches: [main]')
|
||||
expect(deploy).not.toMatch(/^\s*schedule:/m)
|
||||
// Trigger 1: push-only guard; the output default keeps a dispatch flowing.
|
||||
expect(deploy).toContain(' preflight:')
|
||||
expect(deploy).toContain('node scripts/deploy-preflight.mjs')
|
||||
expect(deploy).toContain("if: github.event_name == 'push'")
|
||||
expect(deploy).toContain("steps.guard.outputs.should_deploy || 'true'")
|
||||
expect(deploy).toContain("needs.preflight.outputs.should_deploy == 'true'")
|
||||
// Trigger 2: hosted requeue, token-native dispatch.
|
||||
expect(deploy).toContain(' requeue:')
|
||||
expect(deploy).toContain('node scripts/deploy-requeue.mjs')
|
||||
expect(deploy).toContain('actions: write')
|
||||
// Anti-goal: the requeue stays hosted and OUTSIDE the deploy concurrency
|
||||
// group — a run waiting on the production approval must not block it.
|
||||
const requeueBlock = deploy.slice(
|
||||
deploy.indexOf(' requeue:'),
|
||||
deploy.indexOf(' deploy-production:'),
|
||||
)
|
||||
expect(requeueBlock).toContain('runs-on: ubuntu-latest')
|
||||
expect(requeueBlock).not.toContain('concurrency:')
|
||||
|
||||
// The OPS65 production-change gate died with the Forgejo ci.yml: the
|
||||
// manual dispatch always runs the full suite (verify) before deploy, so
|
||||
// no PR workflow may reference the removed classifier.
|
||||
// full suite (verify) always runs before deploy, so no PR workflow may
|
||||
// reference the removed classifier.
|
||||
const ciPr = readFileSync(join(repoRoot, '.github/workflows/ci-pr.yml'), 'utf8')
|
||||
expect(ciPr).not.toContain('ci-classify-production.mjs')
|
||||
})
|
||||
|
|
@ -127,11 +159,7 @@ describe('ciSkipInvariants', () => {
|
|||
// not in this YAML); the chain is needs-based so a red staging fail-closes
|
||||
// production. Comments are stripped first: the header prose also mentions
|
||||
// these keys, and it must never satisfy the pin.
|
||||
const raw = readFileSync(join(repoRoot, '.github/workflows/deploy.yml'), 'utf8')
|
||||
const deploy = raw
|
||||
.split('\n')
|
||||
.filter((line) => !line.trimStart().startsWith('#'))
|
||||
.join('\n')
|
||||
const deploy = readWorkflow('.github/workflows/deploy.yml')
|
||||
expect(deploy).toContain(' deploy-staging:')
|
||||
expect(deploy).toContain(' deploy-production:')
|
||||
expect(deploy).toContain(' environment: staging')
|
||||
|
|
|
|||
87
tests/unit/deployTrigger.unit.spec.ts
Normal file
87
tests/unit/deployTrigger.unit.spec.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
ACTIVE_RUN_STATUSES,
|
||||
decidePreflight,
|
||||
decideRequeue,
|
||||
} from '../../scripts/lib/deploy-trigger.mjs'
|
||||
|
||||
const run = (id: number, status: string) => ({ id, status })
|
||||
|
||||
describe('deploy-trigger (OPS104)', () => {
|
||||
it('keeps only queued/in_progress as active statuses', () => {
|
||||
expect([...ACTIVE_RUN_STATUSES].sort()).toEqual(['in_progress', 'queued'])
|
||||
})
|
||||
|
||||
it('never blocks a manual dispatch', () => {
|
||||
const verdict = decidePreflight({
|
||||
eventName: 'workflow_dispatch',
|
||||
currentRunId: 1,
|
||||
runs: [run(2, 'in_progress')],
|
||||
})
|
||||
expect(verdict).toEqual({ shouldDeploy: true, reason: 'dispatch-manual', activeRunIds: [] })
|
||||
})
|
||||
|
||||
it('allows a push when no other run is active', () => {
|
||||
const verdict = decidePreflight({
|
||||
eventName: 'push',
|
||||
currentRunId: 10,
|
||||
runs: [run(10, 'in_progress'), run(9, 'completed')],
|
||||
})
|
||||
expect(verdict.shouldDeploy).toBe(true)
|
||||
expect(verdict.reason).toBe('sem-deploy-ativo')
|
||||
})
|
||||
|
||||
it('skips the push when another run is queued or in_progress', () => {
|
||||
const verdict = decidePreflight({
|
||||
eventName: 'push',
|
||||
currentRunId: 10,
|
||||
runs: [run(11, 'queued'), run(12, 'in_progress')],
|
||||
})
|
||||
expect(verdict).toEqual({
|
||||
shouldDeploy: false,
|
||||
reason: 'deploy-ativo',
|
||||
activeRunIds: [11, 12],
|
||||
})
|
||||
})
|
||||
|
||||
it('excludes the current run from the active count', () => {
|
||||
const verdict = decidePreflight({
|
||||
eventName: 'push',
|
||||
currentRunId: '10',
|
||||
runs: [run(10, 'in_progress')],
|
||||
})
|
||||
expect(verdict.shouldDeploy).toBe(true)
|
||||
})
|
||||
|
||||
it('does not count waiting (production approval) or completed runs as active', () => {
|
||||
const verdict = decidePreflight({
|
||||
eventName: 'push',
|
||||
currentRunId: 10,
|
||||
runs: [run(7, 'waiting'), run(8, 'completed'), run(9, 'requested')],
|
||||
})
|
||||
expect(verdict.shouldDeploy).toBe(true)
|
||||
})
|
||||
|
||||
it('decideRequeue: same head is a no-op', () => {
|
||||
expect(decideRequeue({ runSha: 'abc', headSha: 'abc' })).toEqual({
|
||||
dispatch: false,
|
||||
reason: 'head-atual',
|
||||
})
|
||||
})
|
||||
|
||||
it('decideRequeue: a newer head dispatches', () => {
|
||||
expect(decideRequeue({ runSha: 'abc', headSha: 'def' })).toEqual({
|
||||
dispatch: true,
|
||||
reason: 'head-avancou',
|
||||
})
|
||||
})
|
||||
|
||||
it('decideRequeue: a missing SHA is a no-op the CLI treats as an error', () => {
|
||||
expect(decideRequeue({ runSha: undefined, headSha: 'def' })).toEqual({
|
||||
dispatch: false,
|
||||
reason: 'sha-ausente',
|
||||
})
|
||||
expect(decideRequeue({})).toEqual({ dispatch: false, reason: 'sha-ausente' })
|
||||
})
|
||||
})
|
||||
|
|
@ -123,6 +123,74 @@ describe('github-api (issue tracker layer)', () => {
|
|||
})
|
||||
})
|
||||
|
||||
it('listWorkflowRuns queries the workflow runs endpoint and normalizes the payload', async () => {
|
||||
const calls: FetchCall[] = []
|
||||
const api = createApi({
|
||||
token: 'tok',
|
||||
fetchImpl: async (url, init) => {
|
||||
calls.push({ url: String(url), init })
|
||||
return ok({
|
||||
workflow_runs: [
|
||||
{
|
||||
id: 55,
|
||||
status: 'in_progress',
|
||||
conclusion: null,
|
||||
event: 'push',
|
||||
head_sha: 'abc',
|
||||
head_branch: 'main',
|
||||
created_at: '2026-09-14T10:00:00Z',
|
||||
html_url: 'https://github.com/fsolla/teqo/actions/runs/55',
|
||||
},
|
||||
],
|
||||
})
|
||||
},
|
||||
})
|
||||
const runs = await api.listWorkflowRuns('deploy.yml', { status: 'in_progress', branch: 'main' })
|
||||
expect(runs).toEqual([
|
||||
{
|
||||
id: 55,
|
||||
status: 'in_progress',
|
||||
conclusion: null,
|
||||
event: 'push',
|
||||
headSha: 'abc',
|
||||
headBranch: 'main',
|
||||
createdAt: '2026-09-14T10:00:00Z',
|
||||
htmlUrl: 'https://github.com/fsolla/teqo/actions/runs/55',
|
||||
},
|
||||
])
|
||||
expect(calls[0].url).toContain(
|
||||
'/actions/workflows/deploy.yml/runs?status=in_progress&branch=main&per_page=100',
|
||||
)
|
||||
})
|
||||
|
||||
it('listWorkflowRuns tolerates a missing workflow_runs array', async () => {
|
||||
const api = createApi({ token: 'tok', fetchImpl: async () => ok({}) })
|
||||
await expect(api.listWorkflowRuns('deploy.yml')).resolves.toEqual([])
|
||||
})
|
||||
|
||||
it('getBranchHead reads and normalizes the branch ref', async () => {
|
||||
const calls: FetchCall[] = []
|
||||
const api = createApi({
|
||||
token: 'tok',
|
||||
fetchImpl: async (url, init) => {
|
||||
calls.push({ url: String(url), init })
|
||||
return ok({ ref: 'refs/heads/main', object: { sha: 'deadbeef' } })
|
||||
},
|
||||
})
|
||||
const head = await api.getBranchHead('main')
|
||||
expect(head).toEqual({ ref: 'refs/heads/main', sha: 'deadbeef' })
|
||||
expect(calls[0].url).toContain('/git/ref/heads/main')
|
||||
})
|
||||
|
||||
it('getBranchHead returns null on a missing branch (404)', async () => {
|
||||
const api = createApi({
|
||||
token: 'tok',
|
||||
fetchImpl: async () => ok({ message: 'Not Found' }, 404),
|
||||
retries: 0,
|
||||
})
|
||||
await expect(api.getBranchHead('nope')).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('getFileContents base64-decodes content', async () => {
|
||||
const api = createApi({
|
||||
token: 'tok',
|
||||
|
|
|
|||
Loading…
Reference in a new issue