From f15137664151664d4b99cbe0bb743e3aab364c2e Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Mon, 17 Aug 2026 13:17:17 -0300 Subject: [PATCH 1/2] =?UTF-8?q?ops(OPS50):=20remove=20deploy=20Vercel=20do?= =?UTF-8?q?=20pipeline=20=E2=80=94=20produ=C3=A7=C3=A3o=20=C3=A9=20self-ho?= =?UTF-8?q?sted=20no=20homeserver=20(jorgesolla1313.com.br);=20ci.yml=20vi?= =?UTF-8?q?ra=20verificador=20puro=20(sem=20deploy/requeue),=20apaga=20ver?= =?UTF-8?q?cel-promote.yml,=20scripts/tests/vercel.json=20e=20secrets=20VE?= =?UTF-8?q?RCEL=5F*?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agents/rules/agent-pr-workflow.mdc | 8 +- .forgejo/workflows/ci.yml | 153 +--- .forgejo/workflows/vercel-promote.yml | 69 -- AGENTS.md | 8 +- docs/AGENT-OPS.md | 31 +- scripts/lib/vercel-deploy-cooldown.mjs | 122 --- scripts/lib/vercel-production-alias.mjs | 740 ------------------ scripts/vercel-deploy-cooldown.mjs | 68 -- scripts/vercel-ensure-production-alias.mjs | 59 -- scripts/vercel-ignore-build.sh | 20 - tests/unit/vercelDeployCooldown.unit.spec.ts | 107 --- tests/unit/vercelProductionAlias.unit.spec.ts | 369 --------- vercel.json | 8 - 13 files changed, 20 insertions(+), 1742 deletions(-) delete mode 100644 .forgejo/workflows/vercel-promote.yml delete mode 100644 scripts/lib/vercel-deploy-cooldown.mjs delete mode 100644 scripts/lib/vercel-production-alias.mjs delete mode 100644 scripts/vercel-deploy-cooldown.mjs delete mode 100644 scripts/vercel-ensure-production-alias.mjs delete mode 100755 scripts/vercel-ignore-build.sh delete mode 100644 tests/unit/vercelDeployCooldown.unit.spec.ts delete mode 100644 tests/unit/vercelProductionAlias.unit.spec.ts delete mode 100644 vercel.json diff --git a/.agents/rules/agent-pr-workflow.mdc b/.agents/rules/agent-pr-workflow.mdc index d56c85ed..a731ce81 100644 --- a/.agents/rules/agent-pr-workflow.mdc +++ b/.agents/rules/agent-pr-workflow.mdc @@ -64,7 +64,7 @@ Branch protection on `main` requires only: (`migration-lock` removed 2026-08-12 — the CI validates migration chains via migrate + int on every PR/main.) -**Vercel preview is NOT a merge gate.** Production deploy is Actions-gated (`ci.yml` → `vercel deploy --prod` after full suite green). Do not block on Vercel Git status. +**There is no deploy from CI.** The `ci.yml` verifier on `main` runs the full suite and stops (production is self-hosted on the homeserver — `jorgesolla1313.com.br` — deployed via an ops runbook outside this repo). Do not add a Vercel/Git-integration deploy gate. When watching CI: @@ -74,13 +74,9 @@ gh pr checks --watch --required Use `--required` — without it, `--watch` also waits for Vercel. -## Agent branches skip Vercel builds - -All branches skip Vercel Git builds via `scripts/vercel-ignore-build.sh` (deploys only from the main verifier Action). - ## Promote -There is no stage→main promote. Merging to `main` is the delivery; `ci.yml` verifies and deploys. +There is no stage→main promote. Merging to `main` is the delivery; `ci.yml` verifies the full suite and production deploys from the homeserver runbook. ## Safety net (GitHub Action) diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 4893bfd7..718df720 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -1,7 +1,6 @@ # Post-merge verifier on `main`: always the FULL suite (no affected skips). -# When green, deploys production via `vercel deploy --prod` (Vercel Git builds -# are ignored — see scripts/vercel-ignore-build.sh). If HEAD moved while this -# run was in flight, `requeue` dispatches another run for the new tip. +# No deploy step: production is self-hosted on the homeserver +# (jorgesolla1313.com.br), deployed outside this pipeline. # # Forgejo port (OPS50): Postgres services publish to per-job HOST ports on the # runner machine (act/forgejo runner semantics — the canonical 5432 is the @@ -206,151 +205,3 @@ jobs: | all(.value.result == "success" or .value.result == "skipped") ' - deploy: - runs-on: ubuntu-latest - needs: [checks] - if: needs.checks.result == 'success' - # pull → local `vercel build` (pnpm install + Next/Payload build against prod env) → upload. - timeout-minutes: 35 - outputs: - stale: ${{ steps.tip.outputs.stale || 'false' }} - cooldown: ${{ steps.cooldown.outputs.active || 'false' }} - wait_seconds: ${{ steps.cooldown.outputs.wait_seconds || '0' }} - env: - VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} - VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} - VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} - steps: - - uses: actions/checkout@v5 - with: - fetch-depth: 0 - - name: Guard stale HEAD - id: tip - run: | - set -euo pipefail - git fetch origin main --quiet - tip=$(git rev-parse origin/main) - if [ "$tip" != "$GITHUB_SHA" ]; then - echo "Skip deploy: origin/main is $tip, this run verified $GITHUB_SHA" - echo "stale=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - echo "stale=false" >> "$GITHUB_OUTPUT" - # Cooldown script is plain Node — no pnpm. Disable package-manager-cache - # here: setup-node@v5 defaults to true and looks for pnpm before action-setup. - - uses: actions/setup-node@v5 - if: steps.tip.outputs.stale != 'true' - with: - node-version: 24 - package-manager-cache: false - - name: Require Vercel credentials - if: steps.tip.outputs.stale != 'true' - run: | - set -euo pipefail - missing=() - [ -z "${VERCEL_TOKEN:-}" ] && missing+=(VERCEL_TOKEN) - [ -z "${VERCEL_ORG_ID:-}" ] && missing+=(VERCEL_ORG_ID) - [ -z "${VERCEL_PROJECT_ID:-}" ] && missing+=(VERCEL_PROJECT_ID) - if [ "${#missing[@]}" -gt 0 ]; then - echo "::error::Missing repo secrets for gated deploy: ${missing[*]}. Add them under Settings → Secrets, then re-run this workflow." - exit 1 - fi - # Fail closed on obvious paste mistakes (must be team_/prj_ from .vercel/project.json). - case "$VERCEL_ORG_ID" in team_*) ;; *) - echo "::error::VERCEL_ORG_ID must look like team_… (from .vercel/project.json after vercel link)." - exit 1 - ;; - esac - case "$VERCEL_PROJECT_ID" in prj_*) ;; *) - echo "::error::VERCEL_PROJECT_ID must look like prj_… (from .vercel/project.json after vercel link)." - exit 1 - ;; - esac - - name: Check deploy cooldown - id: cooldown - if: steps.tip.outputs.stale != 'true' - run: node scripts/vercel-deploy-cooldown.mjs - - name: Log deploy deferral - if: steps.tip.outputs.stale != 'true' && steps.cooldown.outputs.active == 'true' - run: | - echo "Deploy deferred: last production deploy is younger than 15 minutes." - echo "Requeue will retry in ${{ steps.cooldown.outputs.wait_seconds }}s." - # `vercel build` shells out to pnpm (packageManager in package.json); without - # these steps the runner only has npm → spawn pnpm ENOENT. - - uses: pnpm/action-setup@v6 - if: steps.tip.outputs.stale != 'true' && steps.cooldown.outputs.active != 'true' - - uses: actions/setup-node@v5 - if: steps.tip.outputs.stale != 'true' && steps.cooldown.outputs.active != 'true' - with: - node-version: 24 - cache: pnpm - - name: Install Vercel CLI - if: steps.tip.outputs.stale != 'true' && steps.cooldown.outputs.active != 'true' - run: npm install --global vercel@55 - - name: Deploy production - if: steps.tip.outputs.stale != 'true' && steps.cooldown.outputs.active != 'true' - run: | - set -euo pipefail - # Workflow-level env points at the CI teqo_test service. Drop it so - # `vercel build` uses `.vercel/.env.production.local` from `vercel pull` - # (otherwise Payload migrate hits 127.0.0.1:5432 → ECONNREFUSED). - unset DATABASE_URL PAYLOAD_SECRET NEXT_PUBLIC_SITE_URL - # Canonical CI link (Vercel KB): env VERCEL_*_ID + pull → build → deploy --prebuilt. - # "Could not retrieve Project Settings" usually means bad/expired token or wrong IDs (403). - vercel pull --yes --environment=production --token="$VERCEL_TOKEN" - vercel build --prod --token="$VERCEL_TOKEN" - # stdout is always the deployment URL (CLI contract). Capture it — do not - # pipe the whole CLI log or the exit code / URL get lost. - deploy_url=$(vercel deploy --prebuilt --prod --archive=tgz --token="$VERCEL_TOKEN") - echo "Deployed: $deploy_url" - # Git auto-deploy OFF is intentional. If "Auto-assign Custom Production - # Domains" is also OFF, --prod only stages *.vercel.app and leaves - # pt.jorgesolla.com.br on the previous Current — promote + verify here. - node scripts/vercel-ensure-production-alias.mjs "$deploy_url" - # Docs: promote staged production, then alias (Git Integration is OFF by design). - vercel promote "$deploy_url" --token="$VERCEL_TOKEN" --scope="$VERCEL_ORG_ID" || true - # `--yes` is only valid for `alias rm`, not `alias set` (CLI 55). - vercel alias set "$deploy_url" pt.jorgesolla.com.br --token="$VERCEL_TOKEN" --scope="$VERCEL_ORG_ID" - - requeue: - runs-on: ubuntu-latest - needs: [checks, deploy] - if: always() - timeout-minutes: 40 - steps: - - uses: actions/checkout@v5 - with: - fetch-depth: 0 - - name: Re-dispatch if main advanced or deploy deferred - env: - DEPLOY_RESULT: ${{ needs.deploy.result }} - DEPLOY_COOLDOWN: ${{ needs.deploy.outputs.cooldown }} - DEPLOY_WAIT_SECONDS: ${{ needs.deploy.outputs.wait_seconds }} - run: | - set -euo pipefail - git fetch origin main --quiet - tip=$(git rev-parse origin/main) - if [ "$tip" != "$GITHUB_SHA" ]; then - echo "origin/main moved to $tip (verified $GITHUB_SHA) — requeue ci.yml" - node scripts/forgejo-dispatch.mjs ci.yml main - exit 0 - fi - if [ "${DEPLOY_RESULT:-}" != "success" ]; then - echo "Deploy job did not succeed (${DEPLOY_RESULT:-skipped}) — idle" - exit 0 - fi - if [ "${DEPLOY_COOLDOWN:-}" = "true" ]; then - wait="${DEPLOY_WAIT_SECONDS:-0}" - echo "Deploy deferred by cooldown — waiting ${wait}s then requeue ci.yml" - sleep "$wait" - git fetch origin main --quiet - tip=$(git rev-parse origin/main) - if [ "$tip" != "$GITHUB_SHA" ]; then - echo "main advanced during cooldown wait ($tip) — requeue for new tip" - else - echo "Cooldown elapsed for $GITHUB_SHA — requeue ci.yml" - fi - node scripts/forgejo-dispatch.mjs ci.yml main - exit 0 - fi - echo "HEAD still $GITHUB_SHA — idle" diff --git a/.forgejo/workflows/vercel-promote.yml b/.forgejo/workflows/vercel-promote.yml deleted file mode 100644 index 86e5bdb2..00000000 --- a/.forgejo/workflows/vercel-promote.yml +++ /dev/null @@ -1,69 +0,0 @@ -# Promote a READY production deployment to custom domains (no rebuild). -# Use when `vercel deploy --prod` staged a deployment but left pt.jorgesolla.com.br -# on an older Current (Auto-assign Custom Production Domains OFF). -name: Vercel promote production - -on: - workflow_dispatch: - inputs: - deployment: - description: 'Deployment URL or id (empty = latest READY production)' - required: false - type: string - default: '' - -jobs: - promote: - runs-on: ubuntu-latest - timeout-minutes: 10 - env: - VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} - VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} - VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} - steps: - - uses: actions/checkout@v5 - - uses: actions/setup-node@v5 - with: - node-version: 24 - package-manager-cache: false - - name: Require Vercel credentials - run: | - set -euo pipefail - missing=() - [ -z "${VERCEL_TOKEN:-}" ] && missing+=(VERCEL_TOKEN) - [ -z "${VERCEL_ORG_ID:-}" ] && missing+=(VERCEL_ORG_ID) - [ -z "${VERCEL_PROJECT_ID:-}" ] && missing+=(VERCEL_PROJECT_ID) - if [ "${#missing[@]}" -gt 0 ]; then - echo "::error::Missing secrets: ${missing[*]}" - exit 1 - fi - - name: Install Vercel CLI - run: npm install --global vercel@55 - - name: Promote to custom production domain - env: - DEPLOYMENT_REF: ${{ inputs.deployment }} - run: | - set -euo pipefail - if [ -n "${DEPLOYMENT_REF:-}" ]; then - ref="$DEPLOYMENT_REF" - else - ref=$(node --input-type=module -e ' - import { fetchLatestProductionReadyDeployment } from "./scripts/lib/vercel-production-alias.mjs"; - const d = await fetchLatestProductionReadyDeployment({ - token: process.env.VERCEL_TOKEN, - projectId: process.env.VERCEL_PROJECT_ID, - teamId: process.env.VERCEL_ORG_ID, - }); - if (!d) { - console.error("no READY production deployment"); - process.exit(1); - } - process.stdout.write(d.url ? `https://${d.url}` : d.id); - ') - echo "Latest READY production: $ref" - fi - node scripts/vercel-ensure-production-alias.mjs "$ref" - # Docs: when Auto-assign is/was OFF, prefer `vercel promote` then `alias set`. - vercel promote "$ref" --token="$VERCEL_TOKEN" --scope="$VERCEL_ORG_ID" || true - # `--yes` is only valid for `alias rm`, not `alias set` (CLI 55). - vercel alias set "$ref" pt.jorgesolla.com.br --token="$VERCEL_TOKEN" --scope="$VERCEL_ORG_ID" diff --git a/AGENTS.md b/AGENTS.md index 96f48d56..bcb64206 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,7 +12,7 @@ These are product/architecture constraints — not a ban on editing existing cod - Single Next.js app, three route groups: `(frontend)` public site, `(payload)` admin, `(campaign)` internal campaign tool. No separate Rust service or frontend app for `/campanha`; it is modeled with Payload collections. The campaign roles are `coordinator` (label "Coordenador Geral"), `advisor` ("Assessor"), `leader` ("Liderança"), and `candidate` ("Candidato", unrestricted visibility), all on the separate auth collection `campaignUser` (see "Campaign auth" below). An advisor sees and manages ONLY the municipalities they administer; a leader is locked down to the supporter contact tool. - **2026-07-23 remodel (DEPLOYED to production 2026-07-23): "Município" is the operational unit** (it replaced "Praça", which replaced "Núcleo Eleitoral"). Municípios are PREDEFINED (seeded, geography read-only): 435 rows — one per Bahia municipality (416), except Salvador (19 zone Municípios, ZE 1–19); Camaçari is a single whole municipality (ZE 170/171 aggregated); shared/leaky TSE zones are cut at the municipal boundary. Code identifier: `municipality`. Leaderships are one record per person (`contact` UNIQUE) linked to N municipalities and N organizations; vote pledges are per leadership×municipality with `declaredVotes` (staff-entered, leader-visible) vs `estimatedVotes` (three scenarios, staff-only, hidden from the leader). Campaign production data was reset by the remodel migrations (public-site data untouched). Master plan: `docs/plans/remodelagem-municipios.md` (supersedes `remodelagem-pracas.md`). Details: "Campaign Municípios model" below. -- Hosting stays on Vercel for now. No self-host/Coolify migration in progress. **The functions run in `gru1` (São Paulo), pinned in `vercel.json` — do not drop it back to the `iad1` default.** The Neon database is in `sa-east-1`, which is the same AWS region as `gru1`: with the default `iad1` every Payload query crossed the equator twice (~120 ms per round trip, and a page issues a dozen), which is what made `/campanha` feel broken until 2026-07-26. Hobby allows a single region but lets you choose which one. Verify after any deploy that changes function config: `curl -sI https://pt.jorgesolla.com.br/campanha` must answer `x-vercel-id: gru1::gru1::…`, never `gru1::iad1::…`. +- 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). The CI (`ci.yml` on `main`) is a pure verifier: full suite green, no deploy step. Deploying the homeserver container is an ops runbook outside this repo (infra-solla). The production database still lives on Neon until OPS51 (`docs/plans/ops51-db-neon-para-homeserver.md`, status registrado) and media on Vercel Blob until OPS52 — only the _deploy_ pipeline changed. - Donations are NOT handled in this app — they run through QueroApoiar (`apoiar.me/jorgesolla`, TSE-homologated). The site only needs a "Doar" link/CTA out to that URL. - **Embasamento de produto/domínio:** `docs/research/` guarda o compêndio de literatura (campanha de DF na Bahia, geografia do voto, cartografia eleitoral) e o relatório de discovery aprovado (2026-07-21) com o playbook dado→decisão. Para métricas, análises territoriais, mapa e priorização em `/campanha`, consulte-o antes de inventar — o kernel: a disputa de DF é conta de quociente fragmentada; leitura útil é relativa e local (captura, LQ, quantis, cobertura de meta por pledges), nunca % estadual absoluto. @@ -31,7 +31,7 @@ These are product/architecture constraints — not a ban on editing existing cod - Local dev and tests must NEVER touch production. Local dev runs against a local Docker Postgres (`pnpm db:start`); `pnpm dev` refuses a non-local `DATABASE_URL` (guard: `scripts/guard-dev-db.mjs`) unless `ALLOW_REMOTE_DB=true`. Tests load `.env.test` (`teqo_test`) and refuse any database whose name doesn't end in `_test` (`tests/helpers/assertTestDatabase.ts`). Never repoint `.env`/`.env.local`/`.env.test` `DATABASE_URL` at Neon. Full workflow lives in the `local-database` skill (`.agents/skills/local-database`). - **`pnpm dev` honra o `PORT` do `.env.local` (OPS40):** `next dev` resolve a porta ANTES de `@next/env` carregar os env-files (commander `.env('PORT')` no `bin/next`), então o `PORT=3100+slot` do provisionador de worktree era ignorado e todo dev server subia em 3000. `pnpm dev` hoje passa por `scripts/dev.mjs`: roda o mesmo preflight do `guard-dev-db`, carrega `.env.local` → `.env` (`override:false` — env real do processo vence, como o `PORT` que o webServer do Playwright injeta) e entrega a porta ao `next dev` como **flag CLI `-p`** (`resolveDevPort`/`nextDevArgs` em `scripts/lib/cli.mjs`, unit-testados); sem `PORT` em lugar nenhum, nenhum `-p` é passado e o default 3000 (com `allowRetry`) do Next segue intacto no main repo. -- Schema changes go through committed Payload migrations — `push: false` everywhere, never flip it on against a remote DB. Edit configs → `pnpm migrate:create ` → commit `src/migrations/*` (both `.ts` and `.json`, plus `index.ts`) → `pnpm migrate` locally. `pnpm build` runs `payload migrate`, so **every Vercel deploy applies pending migrations to prod**. Prod is baselined at migration `20260715_163458_initial` — never regenerate or replace the initial migration. Full workflow (incl. hand-written data/reconciliation migrations) lives in the `payload-migrations` skill (`.agents/skills/payload-migrations`). +- Schema changes go through committed Payload migrations — `push: false` everywhere, never flip it on against a remote DB. Edit configs → `pnpm migrate:create ` → commit `src/migrations/*` (both `.ts` and `.json`, plus `index.ts`) → `pnpm migrate` locally. `pnpm build` runs `payload migrate`, so **every production deploy applies pending migrations to prod**. Prod is baselined at migration `20260715_163458_initial` — never regenerate or replace the initial migration. Full workflow (incl. hand-written data/reconciliation migrations) lives in the `payload-migrations` skill (`.agents/skills/payload-migrations`). - `Consent.text` was reconciled from `varchar` to `jsonb` via `20260715_163500_consent_text_to_jsonb` (matching the `richText` field). Any future field-type change is a migration, not a manual DB edit. - To copy production content locally, use `pnpm db:pull` (`scripts/db-pull.mjs`): it only reads prod, only writes local, and excludes supporter PII (`contact`/`signature`/`subscription` row data). - `pnpm db:doctor` diagnoses local database connectivity for the dev AND test targets: it names who holds the port, points at exited/crash-looping containers, and flags Postgres containers sharing one data volume (two postmasters on one PGDATA corrupt the WAL — this destroyed the local cluster's checkpoint on 2026-07-25; the canonical setup is ONE compose container on 5432 hosting `teqo` + `teqo_test`). `pnpm dev` runs the same preflight via `guard-dev-db` and refuses to start with a named remedy instead of a later ECONNREFUSED. `db:start`/`db:stop` pin the compose project with `-p teqo`, so they always target that ONE container even from a worktree directory. **`db:start` is idempotent (OPS37):** with `teqo-postgres-1` up and healthy it is a no-op — a plain `compose up` from another worktree would recreate the shared container and kill every live dev/e2e connection (the compose file's relative `./docker/postgres/init` bind mount makes the config hash differ per worktree); apply a real config/image change on purpose with `pnpm db:start --force-recreate`. @@ -79,7 +79,7 @@ curl -X POST "https:///api/revalidate?tag=global_privacy-policy" \ ``` - **Auth:** send the secret via `x-revalidate-secret` or `Authorization: Bearer `; it is compared to `REVALIDATE_SECRET` with a constant-time check. -- `REVALIDATE_SECRET` must be set in the Vercel **production** env (and in your local env if you want to test the endpoint). It is documented in `.env.example`. +- `REVALIDATE_SECRET` must be set in the **production** env (and in your local env if you want to test the endpoint). It is documented in `.env.example`. - **Tag:** optional `?tag=` query or JSON body `{ "tag": "..." }` (query wins). Allowlist: `posts` (default), `global_privacy-policy`, `election-tse` (bust after re-running `pnpm db:seed:tse` so the cached elections-tab reads refresh), `municipality-catalog` (bust after a migration/seed that adds or renames a município so chip indexes and `unstable_cache` loaders refresh). Unknown tag → `400`. - **Responses:** `200 { revalidated: true, tag: '...' }` on success; `401` if the secret is missing/wrong; `500` if `REVALIDATE_SECRET` is not configured on the server. - This POST is the required last step of the post-seed / direct-DB-change runbook. @@ -162,7 +162,7 @@ The artifact is at **v3**: v2 added E8's `campoFederalVotesByYear` / `federalTal - 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 `) 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. -5. Review Vercel environment variables and migration SQL before deploy; `pnpm build` applies pending migrations to production automatically. **`NEXT_PUBLIC_SITE_URL` (ex.: `https://pt.jorgesolla.com.br`) é obrigatória em Production** — sem ela, `getCampaignInviteBaseURL()` lança em runtime e o fluxo de convites/webauthn/link de import da agenda falha com mensagem genérica (C98, 2026-08-09: produção ficou sem a env e "gerar link" do feed quebrava; o preview tinha a de stage). +5. Review production environment variables and migration SQL before deploy; `pnpm build` applies pending migrations to the deployed database automatically. **`NEXT_PUBLIC_SITE_URL` (ex.: `https://jorgesolla1313.com.br`) é obrigatória em Production** — sem ela, `getCampaignInviteBaseURL()` lança em runtime e o fluxo de convites/webauthn/link de import da agenda falha com mensagem genérica (C98, 2026-08-09: produção ficou sem a env e "gerar link" do feed quebrava; o preview tinha a de stage). 6. **Mandatory production blocker:** do not insert real leadership/support data or enable invites until electoral counsel documents the LGPD art. 11 basis, approves the specific versioned consent text, and an admin creates it with exactly `Consent.key = 'lideranca-autopreenchimento'`. The app intentionally fails closed when this key is absent. 7. **Remodel deploy (historical — applied to production 2026-07-23):** the destructive remodel chain (`20260721_020109_remodel_municipalities` → `20260723_200000_remodel_municipalities` + `20260723_202000_reconcile_municipality_remodel`) already ran in production via the Vercel build: campaign vertical reset, 435 municípios seeded, public-site data untouched. Treat any future destructive migration the same way — review its SQL before the first production build that includes it. diff --git a/docs/AGENT-OPS.md b/docs/AGENT-OPS.md index 0c3e3f78..666d3620 100644 --- a/docs/AGENT-OPS.md +++ b/docs/AGENT-OPS.md @@ -15,7 +15,7 @@ Agentes **nunca** recebem `DATABASE_URL` de prod e nunca setam `ALLOW_REMOTE_DB` ```text claim → feature branch → PR --base main → CI PR green (cascade + skips) → auto-merge em main -main → ci.yml full suite → vercel deploy --prod (se verde) → requeue se HEAD andou +main → ci.yml full suite (verificador puro; sem deploy — produção é self-hosted no homeserver, runbook fora do repo) ``` **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` → `project-status`. `docs/roadmap.md` = legado congelado; fonte canônica = GitHub Issues. @@ -69,14 +69,14 @@ Arquivos de "lista" que N agentes escrevem em paralelo (changelog, plans, migra ## CI por alvo -| Workflow | Trigger | Banco | Passos | -| ------------------------------------ | -------------------------------------- | ------------------- | --------------------------------------------------------------------------------------------------------------------- | -| `ci-pr.yml` | PR → `main` | service Postgres 17 | Fase 1 barata → fase 2 cara; skips via `ci-scope.mjs`; fail-fast cancela o run no 1º job vermelho; rollup `checks` | -| `ci.yml` | push / dispatch `main` | service Postgres | Full sempre; fail-fast cancela o run no 1º job vermelho → `deploy` (`vercel deploy --prod`) → `requeue` se HEAD andou | -| `issue-done-on-main-merge.yml` | PR merged → `main` | — | `Closes`/`Fixes` → `done` + `in-prod` | -| `plan-issue-ready-on-main-merge.yml` | PR merged → `main` | — | `Related #N` aguardando plano → `ready` (OPS18; soft-skip) | -| `agent-pr-ready-automerge.yml` | PR `cursor/*` → `main` (open/sync/…) | — | Draft→Ready + `gh pr merge --auto --rebase` (safety net; audit incluso) | -| `agent-pool.yml` | schedule / PR closed `main` / dispatch | — | Supervisor do pool (`POOL_GITHUB_TOKEN`) | +| Workflow | Trigger | Banco | Passos | +| ------------------------------------ | -------------------------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------- | +| `ci-pr.yml` | PR → `main` | service Postgres 17 | Fase 1 barata → fase 2 cara; skips via `ci-scope.mjs`; fail-fast cancela o run no 1º job vermelho; rollup `checks` | +| `ci.yml` | push / dispatch `main` | service Postgres | Full sempre; fail-fast cancela o run no 1º job vermelho; rollup `checks`; sem deploy (produção self-hosted no homeserver) | +| `issue-done-on-main-merge.yml` | PR merged → `main` | — | `Closes`/`Fixes` → `done` + `in-prod` | +| `plan-issue-ready-on-main-merge.yml` | PR merged → `main` | — | `Related #N` aguardando plano → `ready` (OPS18; soft-skip) | +| `agent-pr-ready-automerge.yml` | PR `cursor/*` → `main` (open/sync/…) | — | Draft→Ready + `gh pr merge --auto --rebase` (safety net; audit incluso) | +| `agent-pool.yml` | schedule / PR closed `main` / dispatch | — | Supervisor do pool (`POOL_GITHUB_TOKEN`) | Action runtimes: `actions/checkout@v5`, `actions/setup-node@v5`, `pnpm/action-setup@v6` e `styfle/cancel-workflow-action@0.13.1` usam Node 24 nativo — sem `FORCE_JAVASCRIPT_ACTIONS_TO_NODE24`. @@ -86,18 +86,11 @@ Fast gate: `pnpm gate:fast`. Push: `pnpm push`. | Secret | Uso | | ------------------- | ------------------------------------------------------------------------------------ | -| `VERCEL_TOKEN` | Deploy gated em `ci.yml` | -| `VERCEL_ORG_ID` | idem | -| `VERCEL_PROJECT_ID` | idem | +| `FORGEJO_API_TOKEN` | Supervisor do pool e scripts Forgejo (`forgejo-api.mjs`) | +| `CURSOR_API_KEY` | Supervisor do pool (Cursor Cloud) | | `POOL_GITHUB_TOKEN` | PAT `actions:write` + `issues:write` (variables do pool; `GITHUB_TOKEN` costuma 403) | -Vercel Git builds: `scripts/vercel-ignore-build.sh` skipeia **todas** as branches (incl. `main`) — prod só via Action. - -**Dois toggles distintos no dashboard Vercel (não confundir):** - -1. **Git / Branch tracking — não disparar build em push** — o que queremos OFF (já pinado em `vercel.json` `git.deploymentEnabled: false` + ignore script). Desligar “automatic deploy from main” no dashboard é o mesmo eixo — **não** confunda com o item 2. -2. **Auto-assign Custom Production Domains** (Environments → Production → Branch Tracking) — deve ficar **ON**. Se OFF, `vercel deploy --prod` no Actions cria um deployment "staged" que só recebe `*.vercel.app` e **não** move `pt.jorgesolla.com.br`. -3. **Domains → `pt.jorgesolla.com.br` → Git Branch** — deve ficar **vazio** (Production). Domínio com Git Branch só auto-alia via Git Integration; com Git deploy OFF + CLI `--prod`, o host fica preso no Current antigo. O step pós-deploy `scripts/vercel-ensure-production-alias.mjs` reativa auto-assign, **limpa gitBranch**, faz `promote` + `POST /v2/deployments/{id}/aliases` + `vercel alias set`, e **não** confia no campo `alias` de `GET /v13` (pode listar o hostname de produção sem o domínio estar nesse deployment). Emergência sem rebuild: workflow `Vercel promote production`. +Sem deploy Vercel: o job `deploy`, `vercel-promote.yml`, os scripts de Vercel e `vercel.json` foram removidos no OPS50 — produção é self-hosted no homeserver (`jorgesolla1313.com.br`), deploy via runbook de infra fora deste repo. ## Cursor Cloud diff --git a/scripts/lib/vercel-deploy-cooldown.mjs b/scripts/lib/vercel-deploy-cooldown.mjs deleted file mode 100644 index 2900fb1d..00000000 --- a/scripts/lib/vercel-deploy-cooldown.mjs +++ /dev/null @@ -1,122 +0,0 @@ -/** - * Production deploy cooldown — queries Vercel for the last READY production - * deployment and computes whether CI should defer another upload (OPS11). - */ - -export const DEPLOY_COOLDOWN_MS = 15 * 60 * 1000 - -const VERCEL_API_BASE = 'https://api.vercel.com' - -/** - * @param {number | null | undefined} lastReadyAtMs - * @param {number} [nowMs] - */ -export const computeCooldownWait = (lastReadyAtMs, nowMs = Date.now()) => { - if (lastReadyAtMs == null || !Number.isFinite(lastReadyAtMs)) { - return { active: false, waitSeconds: 0, ageMs: null } - } - - const ageMs = nowMs - lastReadyAtMs - const remainingMs = DEPLOY_COOLDOWN_MS - ageMs - - if (remainingMs <= 0) { - return { active: false, waitSeconds: 0, ageMs } - } - - return { - active: true, - waitSeconds: Math.ceil(remainingMs / 1000), - ageMs, - } -} - -/** - * @param {unknown} deployment - * @returns {number | null} - */ -export const lastProductionReadyAtFromDeployment = (deployment) => { - if (!deployment || typeof deployment !== 'object') return null - const ready = /** @type {{ ready?: unknown; createdAt?: unknown }} */ (deployment).ready - const createdAt = /** @type {{ ready?: unknown; createdAt?: unknown }} */ (deployment).createdAt - const timestamp = ready ?? createdAt - return typeof timestamp === 'number' && Number.isFinite(timestamp) ? timestamp : null -} - -/** - * @param {{ - * token: string - * projectId: string - * teamId: string - * fetchImpl?: typeof fetch - * }} options - * @returns {Promise} - */ -export const fetchLastProductionReadyAt = async ({ - token, - projectId, - teamId, - fetchImpl = fetch, -}) => { - const params = new URLSearchParams({ - projectId, - teamId, - target: 'production', - state: 'READY', - limit: '1', - }) - - const response = await fetchImpl(`${VERCEL_API_BASE}/v6/deployments?${params}`, { - headers: { authorization: `Bearer ${token}` }, - }) - - const bodyText = await response.text() - let body - try { - body = bodyText ? JSON.parse(bodyText) : {} - } catch { - throw new Error(`Vercel API returned non-JSON (${response.status})`) - } - - if (!response.ok) { - const message = - typeof body?.error?.message === 'string' - ? body.error.message - : bodyText || `HTTP ${response.status}` - throw new Error(`Vercel deployments API ${response.status}: ${message}`) - } - - const deployments = Array.isArray(body.deployments) ? body.deployments : [] - return lastProductionReadyAtFromDeployment(deployments[0]) -} - -/** - * @param {{ - * token: string - * projectId: string - * teamId: string - * fetchImpl?: typeof fetch - * nowMs?: number - * }} options - */ -export const evaluateDeployCooldown = async ({ - token, - projectId, - teamId, - fetchImpl = fetch, - nowMs = Date.now(), -}) => { - const lastProductionDeployAt = await fetchLastProductionReadyAt({ - token, - projectId, - teamId, - fetchImpl, - }) - const { active, waitSeconds, ageMs } = computeCooldownWait(lastProductionDeployAt, nowMs) - - return { - active, - waitSeconds, - ageMs, - lastProductionDeployAt, - } -} diff --git a/scripts/lib/vercel-production-alias.mjs b/scripts/lib/vercel-production-alias.mjs deleted file mode 100644 index 616dcb8c..00000000 --- a/scripts/lib/vercel-production-alias.mjs +++ /dev/null @@ -1,740 +0,0 @@ -/** - * Ensure a Vercel production deployment is assigned to custom domains. - * - * Disabling Git auto-deploy (`git.deploymentEnabled: false`) is correct for - * Actions-gated deploys. Separately, project setting - * `autoAssignCustomDomains` must stay ON — when OFF, `vercel deploy --prod` - * creates a staged production deployment that only gets `*.vercel.app` and - * leaves custom domains (e.g. pt.jorgesolla.com.br) on an older Current. - */ - -const VERCEL_API_BASE = 'https://api.vercel.com' - -/** Canonical Teqo production hostname — WordPress stays on jorgesolla.com.br. */ -export const PRODUCTION_CUSTOM_DOMAIN = 'pt.jorgesolla.com.br' - -/** - * @param {string} value - * @returns {string} - */ -export const normalizeHostname = (value) => - String(value ?? '') - .trim() - .replace(/^https?:\/\//i, '') - .replace(/\/.*$/, '') - .toLowerCase() - -/** - * @param {unknown} aliases - * @param {string} expectedHost - */ -export const aliasesIncludeHost = (aliases, expectedHost) => { - const want = normalizeHostname(expectedHost) - if (!want) return false - if (!Array.isArray(aliases)) return false - return aliases.some((alias) => normalizeHostname(alias) === want) -} - -/** - * Vercel deployment ids appear as `dpl_…` or bare ids depending on the endpoint. - * @param {string | null | undefined} a - * @param {string | null | undefined} b - */ -export const sameDeploymentId = (a, b) => { - const left = String(a ?? '') - .trim() - .replace(/^dpl_/i, '') - .toLowerCase() - const right = String(b ?? '') - .trim() - .replace(/^dpl_/i, '') - .toLowerCase() - return Boolean(left) && left === right -} - -/** - * Normalize a CLI stdout URL / inspect path / bare id into something the - * deployments GET endpoint accepts (`dpl_…`, short id, or hostname). - * @param {string} deploymentRef - * @returns {{ kind: 'id' | 'url'; value: string }} - */ -export const parseDeploymentRef = (deploymentRef) => { - const raw = String(deploymentRef ?? '').trim() - if (!raw) throw new Error('deployment ref is required') - if (/^dpl_[A-Za-z0-9]+$/.test(raw)) return { kind: 'id', value: raw } - - try { - const url = new URL(raw.includes('://') ? raw : `https://${raw}`) - // Dashboard inspect: vercel.com/{team}/{project}/{deploymentId} - if (url.hostname === 'vercel.com') { - const pathId = url.pathname.split('/').filter(Boolean).at(-1) - if (pathId && /^[A-Za-z0-9]+$/.test(pathId)) return { kind: 'id', value: pathId } - } - if (url.hostname.endsWith('.vercel.app') || url.hostname.includes('.')) { - return { kind: 'url', value: url.hostname } - } - } catch { - // fall through - } - - if (/^[A-Za-z0-9_]+$/.test(raw)) return { kind: 'id', value: raw } - return { kind: 'url', value: normalizeHostname(raw) } -} - -/** - * @param {{ - * token: string - * projectId: string - * teamId: string - * fetchImpl?: typeof fetch - * }} options - */ -const fetchVercelProject = async ({ token, projectId, teamId, fetchImpl = fetch }) => { - const params = new URLSearchParams({ teamId }) - const response = await fetchImpl(`${VERCEL_API_BASE}/v9/projects/${projectId}?${params}`, { - headers: { authorization: `Bearer ${token}` }, - }) - const bodyText = await response.text() - let body - try { - body = bodyText ? JSON.parse(bodyText) : {} - } catch { - throw new Error(`Vercel project API returned non-JSON (${response.status})`) - } - if (!response.ok) { - const message = - typeof body?.error?.message === 'string' - ? body.error.message - : bodyText || `HTTP ${response.status}` - throw new Error(`Vercel project API ${response.status}: ${message}`) - } - return body -} - -/** - * @param {{ - * token: string - * projectId: string - * teamId: string - * enabled: boolean - * fetchImpl?: typeof fetch - * }} options - */ -const setAutoAssignCustomDomains = async ({ - token, - projectId, - teamId, - enabled, - fetchImpl = fetch, -}) => { - const params = new URLSearchParams({ teamId }) - const response = await fetchImpl(`${VERCEL_API_BASE}/v9/projects/${projectId}?${params}`, { - method: 'PATCH', - headers: { - authorization: `Bearer ${token}`, - 'content-type': 'application/json', - }, - body: JSON.stringify({ autoAssignCustomDomains: enabled }), - }) - const bodyText = await response.text() - let body - try { - body = bodyText ? JSON.parse(bodyText) : {} - } catch { - throw new Error(`Vercel project PATCH returned non-JSON (${response.status})`) - } - if (!response.ok) { - const message = - typeof body?.error?.message === 'string' - ? body.error.message - : bodyText || `HTTP ${response.status}` - throw new Error(`Vercel project PATCH ${response.status}: ${message}`) - } - return body -} - -/** - * @param {{ - * token: string - * projectId: string - * teamId: string - * deploymentId: string - * fetchImpl?: typeof fetch - * }} options - */ -const promoteProductionDeployment = async ({ - token, - projectId, - teamId, - deploymentId, - fetchImpl = fetch, -}) => { - const params = new URLSearchParams({ teamId }) - const response = await fetchImpl( - `${VERCEL_API_BASE}/v10/projects/${projectId}/promote/${deploymentId}?${params}`, - { - method: 'POST', - headers: { - authorization: `Bearer ${token}`, - 'content-type': 'application/json', - }, - body: '{}', - }, - ) - const bodyText = await response.text() - let body - try { - body = bodyText ? JSON.parse(bodyText) : {} - } catch { - body = { raw: bodyText } - } - if (!response.ok) { - const message = - typeof body?.error?.message === 'string' - ? body.error.message - : bodyText || `HTTP ${response.status}` - throw new Error(`Vercel promote API ${response.status}: ${message}`) - } - return body -} - -/** - * @typedef {{ - * name: string - * gitBranch: string | null - * verified: boolean | null - * redirect: string | null - * customEnvironmentId: string | null - * }} ProjectDomainRow - */ - -/** - * @param {{ - * token: string - * projectId: string - * teamId: string - * fetchImpl?: typeof fetch - * }} options - * @returns {Promise} - */ -const fetchProjectDomains = async ({ token, projectId, teamId, fetchImpl = fetch }) => { - const params = new URLSearchParams({ teamId }) - const response = await fetchImpl( - `${VERCEL_API_BASE}/v9/projects/${projectId}/domains?${params}`, - { - headers: { authorization: `Bearer ${token}` }, - }, - ) - const bodyText = await response.text() - let body - try { - body = bodyText ? JSON.parse(bodyText) : {} - } catch { - throw new Error(`Vercel project domains API returned non-JSON (${response.status})`) - } - if (!response.ok) { - const message = - typeof body?.error?.message === 'string' - ? body.error.message - : bodyText || `HTTP ${response.status}` - throw new Error(`Vercel project domains API ${response.status}: ${message}`) - } - const rows = Array.isArray(body.domains) ? body.domains : Array.isArray(body) ? body : [] - return rows - .filter((row) => row && typeof row.name === 'string' && row.name.trim()) - .map((row) => ({ - name: normalizeHostname(row.name), - gitBranch: typeof row.gitBranch === 'string' && row.gitBranch.trim() ? row.gitBranch : null, - verified: typeof row.verified === 'boolean' ? row.verified : null, - redirect: typeof row.redirect === 'string' && row.redirect.trim() ? row.redirect : null, - customEnvironmentId: - typeof row.customEnvironmentId === 'string' && row.customEnvironmentId.trim() - ? row.customEnvironmentId - : null, - })) -} - -/** - * Domains with a Git Branch set only auto-alias via Git Integration — CLI `--prod` - * deploys (our Actions path) will not move them until the branch binding is cleared. - * @param {{ - * token: string - * projectId: string - * teamId: string - * domain: string - * fetchImpl?: typeof fetch - * }} options - */ -const clearProjectDomainGitBranch = async ({ - token, - projectId, - teamId, - domain, - fetchImpl = fetch, -}) => { - const host = normalizeHostname(domain) - const params = new URLSearchParams({ teamId }) - const response = await fetchImpl( - `${VERCEL_API_BASE}/v9/projects/${projectId}/domains/${encodeURIComponent(host)}?${params}`, - { - method: 'PATCH', - headers: { - authorization: `Bearer ${token}`, - 'content-type': 'application/json', - }, - // JSON null clears the branch binding (returns domain to Production). - body: JSON.stringify({ gitBranch: null }), - }, - ) - const bodyText = await response.text() - let body - try { - body = bodyText ? JSON.parse(bodyText) : {} - } catch { - throw new Error(`Vercel domain PATCH returned non-JSON (${response.status})`) - } - if (!response.ok) { - const message = - typeof body?.error?.message === 'string' - ? body.error.message - : bodyText || `HTTP ${response.status}` - throw new Error(`Vercel domain PATCH ${response.status}: ${message}`) - } - return body -} - -/** - * Explicit alias assignment — required when custom domains don't ride Git Integration - * auto-assign (CLI / prebuilt deploys). - * @param {{ - * token: string - * teamId: string - * deploymentId: string - * alias: string - * fetchImpl?: typeof fetch - * }} options - */ -const assignDeploymentAlias = async ({ token, teamId, deploymentId, alias, fetchImpl = fetch }) => { - const params = new URLSearchParams({ teamId }) - const response = await fetchImpl( - `${VERCEL_API_BASE}/v2/deployments/${encodeURIComponent(deploymentId)}/aliases?${params}`, - { - method: 'POST', - headers: { - authorization: `Bearer ${token}`, - 'content-type': 'application/json', - }, - body: JSON.stringify({ alias }), - }, - ) - const bodyText = await response.text() - let body - try { - body = bodyText ? JSON.parse(bodyText) : {} - } catch { - body = { raw: bodyText } - } - if (response.ok) { - const assigned = - typeof body?.alias === 'string' ? normalizeHostname(body.alias) : normalizeHostname(alias) - return { ok: true, body, assigned, already: false } - } - const message = - typeof body?.error?.message === 'string' - ? body.error.message - : bodyText || `HTTP ${response.status}` - // Only treat the exact "already on this deployment" 409 as success — other 409s - // (e.g. "domain is not allowed") must fail loudly. - if (response.status === 409 && /already assigned to (the )?given deployment/i.test(message)) { - return { ok: true, body, assigned: normalizeHostname(alias), already: true } - } - throw new Error(`Vercel alias API ${response.status}: ${message}`) -} - -/** - * Prefer the aliases list endpoint — deployment.alias on GET /v13 can omit custom hosts. - * @param {{ - * token: string - * teamId: string - * deploymentId: string - * fetchImpl?: typeof fetch - * }} options - * @returns {Promise} - */ -const listDeploymentAliasHosts = async ({ token, teamId, deploymentId, fetchImpl = fetch }) => { - const params = new URLSearchParams({ teamId }) - const response = await fetchImpl( - `${VERCEL_API_BASE}/v2/deployments/${encodeURIComponent(deploymentId)}/aliases?${params}`, - { - headers: { authorization: `Bearer ${token}` }, - }, - ) - const bodyText = await response.text() - let body - try { - body = bodyText ? JSON.parse(bodyText) : {} - } catch { - throw new Error(`Vercel list-aliases API returned non-JSON (${response.status})`) - } - if (!response.ok) { - const message = - typeof body?.error?.message === 'string' - ? body.error.message - : bodyText || `HTTP ${response.status}` - throw new Error(`Vercel list-aliases API ${response.status}: ${message}`) - } - const rows = Array.isArray(body.aliases) ? body.aliases : [] - return rows - .map((row) => (row && typeof row.alias === 'string' ? normalizeHostname(row.alias) : '')) - .filter(Boolean) -} - -/** - * @param {{ - * token: string - * teamId: string - * domain: string - * fetchImpl?: typeof fetch - * }} options - * @returns {Promise<{ alias: string, deploymentId: string | null } | null>} - */ -const fetchDomainAliasTarget = async ({ token, teamId, domain, fetchImpl = fetch }) => { - const host = normalizeHostname(domain) - const params = new URLSearchParams({ teamId, domain: host, limit: '20' }) - const response = await fetchImpl(`${VERCEL_API_BASE}/v4/aliases?${params}`, { - headers: { authorization: `Bearer ${token}` }, - }) - const bodyText = await response.text() - let body - try { - body = bodyText ? JSON.parse(bodyText) : {} - } catch { - throw new Error(`Vercel aliases-by-domain API returned non-JSON (${response.status})`) - } - if (!response.ok) { - const message = - typeof body?.error?.message === 'string' - ? body.error.message - : bodyText || `HTTP ${response.status}` - throw new Error(`Vercel aliases-by-domain API ${response.status}: ${message}`) - } - const rows = Array.isArray(body.aliases) ? body.aliases : [] - const match = rows.find((row) => normalizeHostname(row?.alias) === host) - if (!match) return null - return { - alias: normalizeHostname(match.alias), - deploymentId: typeof match.deploymentId === 'string' ? match.deploymentId : null, - } -} - -/** - * @param {{ - * token: string - * teamId: string - * deploymentIdOrUrl: string - * fetchImpl?: typeof fetch - * }} options - */ -const fetchDeploymentAliases = async ({ token, teamId, deploymentIdOrUrl, fetchImpl = fetch }) => { - const parsed = parseDeploymentRef(deploymentIdOrUrl) - const params = new URLSearchParams({ teamId }) - // GET /v13/deployments/{idOrUrl} accepts dpl_ id, short id, or hostname. - const pathValue = encodeURIComponent(parsed.value) - const response = await fetchImpl(`${VERCEL_API_BASE}/v13/deployments/${pathValue}?${params}`, { - headers: { authorization: `Bearer ${token}` }, - }) - const bodyText = await response.text() - let body - try { - body = bodyText ? JSON.parse(bodyText) : {} - } catch { - throw new Error(`Vercel deployment API returned non-JSON (${response.status})`) - } - if (!response.ok) { - const message = - typeof body?.error?.message === 'string' - ? body.error.message - : bodyText || `HTTP ${response.status}` - throw new Error(`Vercel deployment API ${response.status}: ${message}`) - } - const aliases = Array.isArray(body.alias) - ? body.alias - : Array.isArray(body.aliases) - ? body.aliases - : [] - return { - id: typeof body.id === 'string' ? body.id : parsed.value, - url: typeof body.url === 'string' ? body.url : null, - aliases, - readyState: body.readyState ?? body.status ?? null, - } -} - -/** - * @param {{ - * token: string - * projectId: string - * teamId: string - * fetchImpl?: typeof fetch - * }} options - * @returns {Promise<{ id: string, url: string | null, ready: number | null } | null>} - */ -export const fetchLatestProductionReadyDeployment = async ({ - token, - projectId, - teamId, - fetchImpl = fetch, -}) => { - const params = new URLSearchParams({ - projectId, - teamId, - target: 'production', - state: 'READY', - limit: '1', - }) - const response = await fetchImpl(`${VERCEL_API_BASE}/v6/deployments?${params}`, { - headers: { authorization: `Bearer ${token}` }, - }) - const bodyText = await response.text() - let body - try { - body = bodyText ? JSON.parse(bodyText) : {} - } catch { - throw new Error(`Vercel deployments list returned non-JSON (${response.status})`) - } - if (!response.ok) { - const message = - typeof body?.error?.message === 'string' - ? body.error.message - : bodyText || `HTTP ${response.status}` - throw new Error(`Vercel deployments list ${response.status}: ${message}`) - } - const deployment = Array.isArray(body.deployments) ? body.deployments[0] : null - if (!deployment || typeof deployment !== 'object') return null - const id = typeof deployment.uid === 'string' ? deployment.uid : deployment.id - if (typeof id !== 'string') return null - return { - id, - url: typeof deployment.url === 'string' ? deployment.url : null, - ready: typeof deployment.ready === 'number' ? deployment.ready : null, - } -} - -/** - * @param {{ - * token: string - * projectId: string - * teamId: string - * deploymentRef: string - * expectedHost?: string - * fetchImpl?: typeof fetch - * sleepImpl?: (ms: number) => Promise - * onStep?: (message: string) => void - * }} options - */ -export const ensureProductionCustomDomain = async ({ - token, - projectId, - teamId, - deploymentRef, - expectedHost = PRODUCTION_CUSTOM_DOMAIN, - fetchImpl = fetch, - sleepImpl = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), - onStep, -}) => { - const steps = [] - const note = (message) => { - steps.push(message) - onStep?.(message) - } - - const project = await fetchVercelProject({ token, projectId, teamId, fetchImpl }) - const autoAssign = project.autoAssignCustomDomains - - if (autoAssign === false) { - note('autoAssignCustomDomains was false — enabling (Git OFF ≠ domain assign OFF)') - await setAutoAssignCustomDomains({ - token, - projectId, - teamId, - enabled: true, - fetchImpl, - }) - } else { - note(`autoAssignCustomDomains=${String(autoAssign)}`) - } - - const projectDomains = await fetchProjectDomains({ token, projectId, teamId, fetchImpl }) - note( - projectDomains.length > 0 - ? `project domains: ${projectDomains - .map((d) => { - const flags = [ - d.gitBranch ? `gitBranch=${d.gitBranch}` : null, - d.redirect ? `redirect=${d.redirect}` : null, - d.customEnvironmentId ? `customEnv=${d.customEnvironmentId}` : null, - d.verified === false ? 'unverified' : null, - ].filter(Boolean) - return flags.length > 0 ? `${d.name}(${flags.join(',')})` : d.name - }) - .join(', ')}` - : 'project domains: (none)', - ) - const expected = normalizeHostname(expectedHost) - const domainRow = projectDomains.find((d) => d.name === expected) - if (!domainRow) { - throw new Error( - `${expectedHost} is not attached to this Vercel project. Add it under Project → Settings → Domains, then re-run. Domains seen: ${JSON.stringify(projectDomains.map((d) => d.name))}`, - ) - } - if (domainRow.verified === false) { - throw new Error( - `${expectedHost} is on the project but not verified — finish DNS/TXT verification in Vercel Domains before aliasing.`, - ) - } - if (domainRow.redirect) { - note( - `warning: ${expectedHost} redirects to ${domainRow.redirect} — alias may not serve app traffic`, - ) - } - // Git-branch-bound domains only move with Git Integration deploys. We deploy via - // CLI (`vercel deploy --prebuilt --prod`), so clear the binding first. - if (domainRow.gitBranch) { - note( - `${expectedHost} had gitBranch=${domainRow.gitBranch} — clearing so CLI/Actions deploys can own Production`, - ) - await clearProjectDomainGitBranch({ - token, - projectId, - teamId, - domain: expected, - fetchImpl, - }) - } - - const deployment = await fetchDeploymentAliases({ - token, - teamId, - deploymentIdOrUrl: deploymentRef, - fetchImpl, - }) - note(`deployment ${deployment.id} readyState=${String(deployment.readyState)}`) - - const hostAlreadyOnDeployment = async () => { - const hosts = await listDeploymentAliasHosts({ - token, - teamId, - deploymentId: deployment.id, - fetchImpl, - }) - if (aliasesIncludeHost(hosts, expectedHost)) { - return { onDeployment: true, hosts, domainTarget: null } - } - const domainTarget = await fetchDomainAliasTarget({ - token, - teamId, - domain: expectedHost, - fetchImpl, - }) - return { - onDeployment: sameDeploymentId(domainTarget?.deploymentId, deployment.id), - hosts, - domainTarget, - } - } - - // Do NOT trust GET /v13 `alias` — it can list the production hostname on a - // fresh --prod deploy even when the custom domain still points at an older - // Current (observed: ensure short-circuited and left pt.jorgesolla.com.br stale). - const ownership = await hostAlreadyOnDeployment() - note( - `domain ${expectedHost} → ${ownership.domainTarget?.deploymentId ?? '(via deployment aliases)'} ` + - `(this=${deployment.id}; listed=${JSON.stringify(ownership.hosts)})`, - ) - if (ownership.onDeployment) { - note(`already aliased to ${expectedHost}`) - return { - alreadyAssigned: true, - promoted: false, - aliased: false, - autoAssignWasFalse: autoAssign === false, - clearedGitBranch: Boolean(domainRow.gitBranch), - deployment, - steps, - } - } - if (ownership.domainTarget?.deploymentId) { - note( - `${expectedHost} still on ${ownership.domainTarget.deploymentId} — will promote/alias onto ${deployment.id}`, - ) - } - - let promoted = false - try { - note(`promoting ${deployment.id}`) - await promoteProductionDeployment({ - token, - projectId, - teamId, - deploymentId: deployment.id, - fetchImpl, - }) - promoted = true - } catch (error) { - note(`promote skipped: ${error instanceof Error ? error.message : String(error)}`) - } - - note(`assigning alias ${expectedHost} → ${deployment.id}`) - const assignResult = await assignDeploymentAlias({ - token, - teamId, - deploymentId: deployment.id, - alias: expected, - fetchImpl, - }) - const assignedHost = assignResult.assigned ?? expected - if (assignedHost !== expected) { - throw new Error( - `Alias API returned unexpected host ${JSON.stringify(assignedHost)} (wanted ${expected}). body=${JSON.stringify(assignResult.body)}`, - ) - } - note( - assignResult.already - ? `alias API: already on this deployment` - : `alias API: assigned ${expectedHost}` + - (assignResult.body?.oldDeploymentId - ? ` (was ${String(assignResult.body.oldDeploymentId)})` - : ''), - ) - - let verified = false - for (let attempt = 0; attempt < 10; attempt += 1) { - const check = await hostAlreadyOnDeployment() - if (check.onDeployment) { - verified = true - break - } - await sleepImpl(1500) - } - - if (!verified) { - const check = await hostAlreadyOnDeployment() - throw new Error( - `After alias assign, ${expectedHost} is still not on deployment ${deployment.id}. ` + - `deployment aliases=${JSON.stringify(check.hosts)}; domain points to=${JSON.stringify(check.domainTarget)}; ` + - `aliasApiBody=${JSON.stringify(assignResult.body)}. ` + - `Check Vercel → Settings → Domains: Git Branch must be empty, and Environments → Production → Auto-assign Custom Production Domains must be ON.`, - ) - } - - note(`verified alias ${expectedHost}`) - return { - alreadyAssigned: false, - promoted, - aliased: true, - autoAssignWasFalse: autoAssign === false, - clearedGitBranch: Boolean(domainRow.gitBranch), - deployment, - steps, - } -} diff --git a/scripts/vercel-deploy-cooldown.mjs b/scripts/vercel-deploy-cooldown.mjs deleted file mode 100644 index 4aeda527..00000000 --- a/scripts/vercel-deploy-cooldown.mjs +++ /dev/null @@ -1,68 +0,0 @@ -#!/usr/bin/env node -/** - * vercel-deploy-cooldown — CI helper for OPS11. Queries the last READY - * production deployment on Vercel and writes GitHub Actions outputs: - * active=true|false - * wait_seconds= - * last_production_deploy_at= - */ -import { appendFileSync } from 'node:fs' - -import { dieWithLabel } from './lib/cli.mjs' -import { DEPLOY_COOLDOWN_MS, evaluateDeployCooldown } from './lib/vercel-deploy-cooldown.mjs' - -const die = dieWithLabel('vercel-deploy-cooldown') - -const writeGithubOutput = (pairs) => { - const outputPath = process.env.GITHUB_OUTPUT - const lines = Object.entries(pairs) - .map(([key, value]) => `${key}=${value}`) - .join('\n') - .concat('\n') - - if (outputPath) { - appendFileSync(outputPath, lines) - return - } - - process.stdout.write(lines) -} - -const main = async () => { - const token = process.env.VERCEL_TOKEN - const projectId = process.env.VERCEL_PROJECT_ID - const teamId = process.env.VERCEL_ORG_ID - - if (!token) die('VERCEL_TOKEN is required') - if (!projectId) die('VERCEL_PROJECT_ID is required') - if (!teamId) die('VERCEL_ORG_ID is required') - - const result = await evaluateDeployCooldown({ token, projectId, teamId }) - - if (result.lastProductionDeployAt == null) { - console.log( - `[vercel-deploy-cooldown] no READY production deployment found — proceed (${DEPLOY_COOLDOWN_MS / 60_000} min cooldown N/A)`, - ) - } else if (result.active) { - const ageMinutes = Math.floor((result.ageMs ?? 0) / 60_000) - console.log( - `[vercel-deploy-cooldown] defer deploy — last production deploy ${ageMinutes} min ago; wait ${result.waitSeconds}s`, - ) - } else { - const ageMinutes = Math.floor((result.ageMs ?? 0) / 60_000) - console.log( - `[vercel-deploy-cooldown] cooldown clear — last production deploy ${ageMinutes} min ago`, - ) - } - - writeGithubOutput({ - active: result.active ? 'true' : 'false', - wait_seconds: String(result.waitSeconds), - last_production_deploy_at: - result.lastProductionDeployAt == null ? '' : String(result.lastProductionDeployAt), - }) -} - -main().catch((error) => { - die(error instanceof Error ? error.message : String(error)) -}) diff --git a/scripts/vercel-ensure-production-alias.mjs b/scripts/vercel-ensure-production-alias.mjs deleted file mode 100644 index fd67a1b7..00000000 --- a/scripts/vercel-ensure-production-alias.mjs +++ /dev/null @@ -1,59 +0,0 @@ -#!/usr/bin/env node -/** - * vercel-ensure-production-alias — after `vercel deploy --prod`, make sure the - * custom production hostname (pt.jorgesolla.com.br) points at the deployment. - * - * Usage: - * node scripts/vercel-ensure-production-alias.mjs - * node scripts/vercel-ensure-production-alias.mjs --latest - * - * Env: VERCEL_TOKEN, VERCEL_PROJECT_ID, VERCEL_ORG_ID - */ -import { dieWithLabel } from './lib/cli.mjs' -import { - ensureProductionCustomDomain, - fetchLatestProductionReadyDeployment, - PRODUCTION_CUSTOM_DOMAIN, -} from './lib/vercel-production-alias.mjs' - -const die = dieWithLabel('vercel-ensure-production-alias') - -const main = async () => { - const token = process.env.VERCEL_TOKEN - const projectId = process.env.VERCEL_PROJECT_ID - const teamId = process.env.VERCEL_ORG_ID - if (!token) die('VERCEL_TOKEN is required') - if (!projectId) die('VERCEL_PROJECT_ID is required') - if (!teamId) die('VERCEL_ORG_ID is required') - - const arg = process.argv[2] - if (!arg) { - die('pass a deployment URL/id, or --latest') - } - - let deploymentRef = arg - if (arg === '--latest') { - const latest = await fetchLatestProductionReadyDeployment({ token, projectId, teamId }) - if (!latest) die('no READY production deployment found') - deploymentRef = latest.url ? `https://${latest.url}` : latest.id - console.log(`[vercel-ensure-production-alias] latest READY production: ${deploymentRef}`) - } - - const result = await ensureProductionCustomDomain({ - token, - projectId, - teamId, - deploymentRef, - expectedHost: PRODUCTION_CUSTOM_DOMAIN, - onStep: (message) => console.log(`[vercel-ensure-production-alias] ${message}`), - }) - - console.log( - `[vercel-ensure-production-alias] ok — ${PRODUCTION_CUSTOM_DOMAIN} → ${result.deployment.id}` + - (result.aliased ? ' (aliased)' : result.alreadyAssigned ? ' (already assigned)' : ''), - ) -} - -main().catch((error) => { - die(error instanceof Error ? error.message : String(error)) -}) diff --git a/scripts/vercel-ignore-build.sh b/scripts/vercel-ignore-build.sh deleted file mode 100755 index 210a4f04..00000000 --- a/scripts/vercel-ignore-build.sh +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env bash -# Vercel "Ignored Build Step": exit 0 = skip deployment, exit 1 = build. -# Production deploys are gated by GitHub Actions (`ci.yml` → `vercel deploy --prod`). -# Git pushes must NOT trigger Vercel builds (Hobby concurrency + ungated deploy race). -set -euo pipefail - -ref="${VERCEL_GIT_COMMIT_REF:-}" - -if [ "$ref" = "main" ] || [ "$ref" = "stage" ]; then - echo "skip: $ref — production deploy is Actions-gated (ci.yml), not Vercel Git" - exit 0 -fi - -if [[ "$ref" == agent/* ]] || [[ "$ref" == cursor/* ]]; then - echo "skip: agent/cursor branch ($ref) — merge gate is GitHub Actions, not Vercel" - exit 0 -fi - -echo "skip: non-canonical branch ($ref) — no Vercel Git builds" -exit 0 diff --git a/tests/unit/vercelDeployCooldown.unit.spec.ts b/tests/unit/vercelDeployCooldown.unit.spec.ts deleted file mode 100644 index f2b52709..00000000 --- a/tests/unit/vercelDeployCooldown.unit.spec.ts +++ /dev/null @@ -1,107 +0,0 @@ -// @vitest-environment node - -import { describe, expect, it } from 'vitest' - -import { - computeCooldownWait, - DEPLOY_COOLDOWN_MS, - evaluateDeployCooldown, - fetchLastProductionReadyAt, - lastProductionReadyAtFromDeployment, -} from '../../scripts/lib/vercel-deploy-cooldown.mjs' - -const fakeResponse = (ok: boolean, status: number, payload: unknown) => - ({ - ok, - status, - text: async () => (payload === null ? '' : JSON.stringify(payload)), - }) as Response - -const captureFetch = (response: Response) => { - const calls: Array<{ url: string; init: RequestInit }> = [] - const fetchImpl = async (url: string | URL | Request, init?: RequestInit) => { - calls.push({ url: String(url), init: init ?? {} }) - return response - } - return { calls, fetchImpl: fetchImpl as typeof fetch } -} - -describe('vercelDeployCooldown', () => { - it('uses a 15-minute cooldown constant', () => { - expect(DEPLOY_COOLDOWN_MS).toBe(15 * 60 * 1000) - }) - - it('lastProductionReadyAtFromDeployment prefers ready over createdAt', () => { - expect(lastProductionReadyAtFromDeployment({ ready: 100, createdAt: 50 })).toBe(100) - expect(lastProductionReadyAtFromDeployment({ createdAt: 50 })).toBe(50) - expect(lastProductionReadyAtFromDeployment(null)).toBeNull() - }) - - it('computeCooldownWait is inactive when no prior deploy exists', () => { - expect(computeCooldownWait(null, 1_000_000)).toEqual({ - active: false, - waitSeconds: 0, - ageMs: null, - }) - }) - - it('computeCooldownWait defers when the last deploy is younger than 15 minutes', () => { - const now = 1_700_000_000_000 - const last = now - 10 * 60 * 1000 - - expect(computeCooldownWait(last, now)).toEqual({ - active: true, - waitSeconds: 5 * 60, - ageMs: 10 * 60 * 1000, - }) - }) - - it('computeCooldownWait clears when the last deploy is older than 15 minutes', () => { - const now = 1_700_000_000_000 - const last = now - 16 * 60 * 1000 - - expect(computeCooldownWait(last, now)).toEqual({ - active: false, - waitSeconds: 0, - ageMs: 16 * 60 * 1000, - }) - }) - - it('fetchLastProductionReadyAt reads the newest READY production deployment', async () => { - const { calls, fetchImpl } = captureFetch( - fakeResponse(true, 200, { - deployments: [{ ready: 1_700_000_000_000, createdAt: 1_699_999_000_000 }], - }), - ) - - await expect( - fetchLastProductionReadyAt({ - token: 'token', - projectId: 'prj_test', - teamId: 'team_test', - fetchImpl, - }), - ).resolves.toBe(1_700_000_000_000) - - expect(calls[0]!.url).toContain('/v6/deployments?') - expect(calls[0]!.url).toContain('projectId=prj_test') - expect(calls[0]!.url).toContain('teamId=team_test') - expect(calls[0]!.url).toContain('target=production') - expect(calls[0]!.url).toContain('state=READY') - }) - - it('evaluateDeployCooldown surfaces API failures', async () => { - const { fetchImpl } = captureFetch( - fakeResponse(false, 403, { error: { message: 'forbidden' } }), - ) - - await expect( - evaluateDeployCooldown({ - token: 'token', - projectId: 'prj_test', - teamId: 'team_test', - fetchImpl, - }), - ).rejects.toThrow(/403/) - }) -}) diff --git a/tests/unit/vercelProductionAlias.unit.spec.ts b/tests/unit/vercelProductionAlias.unit.spec.ts deleted file mode 100644 index 82f006c5..00000000 --- a/tests/unit/vercelProductionAlias.unit.spec.ts +++ /dev/null @@ -1,369 +0,0 @@ -// @vitest-environment node - -import { describe, expect, it } from 'vitest' - -import { - aliasesIncludeHost, - ensureProductionCustomDomain, - normalizeHostname, - parseDeploymentRef, - PRODUCTION_CUSTOM_DOMAIN, - sameDeploymentId, -} from '../../scripts/lib/vercel-production-alias.mjs' - -const jsonResponse = (ok: boolean, status: number, payload: unknown) => - ({ - ok, - status, - text: async () => (payload === null ? '' : JSON.stringify(payload)), - }) as Response - -describe('vercelProductionAlias', () => { - it('pins the Teqo production hostname', () => { - expect(PRODUCTION_CUSTOM_DOMAIN).toBe('pt.jorgesolla.com.br') - }) - - it('normalizeHostname strips protocol and path', () => { - expect(normalizeHostname('https://PT.jorgesolla.com.br/campanha')).toBe('pt.jorgesolla.com.br') - }) - - it('aliasesIncludeHost matches hostnames case-insensitively', () => { - expect( - aliasesIncludeHost( - ['jorgesolla-solla.vercel.app', 'pt.jorgesolla.com.br'], - 'PT.jorgesolla.com.br', - ), - ).toBe(true) - expect(aliasesIncludeHost(['jorgesolla-solla.vercel.app'], 'pt.jorgesolla.com.br')).toBe(false) - }) - - it('sameDeploymentId ignores dpl_ prefix', () => { - expect(sameDeploymentId('dpl_abc', 'abc')).toBe(true) - expect(sameDeploymentId('dpl_abc', 'dpl_abc')).toBe(true) - expect(sameDeploymentId('dpl_abc', 'dpl_other')).toBe(false) - }) - - it('ensureProductionCustomDomain does not trust GET /v13 alias alone', async () => { - // v13 lists the custom host (false positive observed in CI) while the domain - // alias target still points at an older deployment — must promote/alias. - let aliasHosts = ['jorgesolla-solla.vercel.app'] - let domainTargetId = 'dpl_old' - const fetchImpl = (async (url: string | URL | Request, init?: RequestInit) => { - const href = String(url) - const method = init?.method ?? 'GET' - if (href.includes('/v9/projects/') && href.includes('/domains') && method === 'GET') { - return jsonResponse(true, 200, { - domains: [{ name: 'pt.jorgesolla.com.br', verified: true }], - }) - } - if (href.includes('/v9/projects/') && method === 'GET') { - return jsonResponse(true, 200, { autoAssignCustomDomains: true }) - } - if (href.includes('/v13/deployments/')) { - return jsonResponse(true, 200, { - id: 'dpl_new', - // Misleading: listed even though domain still points at dpl_old. - alias: ['pt.jorgesolla.com.br', 'jorgesolla-solla.vercel.app'], - readyState: 'READY', - }) - } - if (href.includes('/v2/deployments/') && href.includes('/aliases') && method === 'GET') { - return jsonResponse(true, 200, { - aliases: aliasHosts.map((alias) => ({ alias, uid: alias, created: '' })), - }) - } - if (href.includes('/v4/aliases') && method === 'GET') { - return jsonResponse(true, 200, { - aliases: [ - { - alias: 'pt.jorgesolla.com.br', - deploymentId: domainTargetId, - uid: 'a1', - created: '', - }, - ], - }) - } - if (href.includes('/promote/') && method === 'POST') { - return jsonResponse(true, 200, {}) - } - if (href.includes('/aliases') && method === 'POST') { - aliasHosts = ['jorgesolla-solla.vercel.app', 'pt.jorgesolla.com.br'] - domainTargetId = 'dpl_new' - return jsonResponse(true, 200, { - alias: 'pt.jorgesolla.com.br', - uid: 'alias_1', - oldDeploymentId: 'dpl_old', - created: new Date().toISOString(), - }) - } - throw new Error(`unexpected fetch ${method} ${href}`) - }) as typeof fetch - - const result = await ensureProductionCustomDomain({ - token: 'tok', - projectId: 'prj_x', - teamId: 'team_x', - deploymentRef: 'dpl_new', - fetchImpl, - sleepImpl: async () => {}, - }) - - expect(result.alreadyAssigned).toBe(false) - expect(result.aliased).toBe(true) - }) - - it('parseDeploymentRef accepts inspect paths and vercel.app hosts', () => { - expect( - parseDeploymentRef('https://vercel.com/solla/jorgesolla/CQkmCejZSvRoFtYP8hHbjzP3pc1L'), - ).toEqual({ - kind: 'id', - value: 'CQkmCejZSvRoFtYP8hHbjzP3pc1L', - }) - expect(parseDeploymentRef('https://jorgesolla-k0lwit3lp-solla.vercel.app')).toEqual({ - kind: 'url', - value: 'jorgesolla-k0lwit3lp-solla.vercel.app', - }) - expect(parseDeploymentRef('dpl_abc123')).toEqual({ kind: 'id', value: 'dpl_abc123' }) - }) - - it('ensureProductionCustomDomain enables auto-assign, promotes, and aliases explicitly', async () => { - const calls: string[] = [] - let aliasHosts = ['jorgesolla-solla.vercel.app'] - const fetchImpl = (async (url: string | URL | Request, init?: RequestInit) => { - const href = String(url) - const method = init?.method ?? 'GET' - calls.push(`${method} ${href}`) - - if (href.includes('/v9/projects/') && href.includes('/domains') && method === 'GET') { - return jsonResponse(true, 200, { - domains: [{ name: 'pt.jorgesolla.com.br', verified: true, gitBranch: null }], - }) - } - if (href.includes('/v9/projects/') && method === 'GET') { - return jsonResponse(true, 200, { autoAssignCustomDomains: false, name: 'jorgesolla' }) - } - if (href.includes('/v9/projects/') && method === 'PATCH' && !href.includes('/domains/')) { - return jsonResponse(true, 200, { autoAssignCustomDomains: true }) - } - if (href.includes('/v13/deployments/')) { - return jsonResponse(true, 200, { - id: 'dpl_test', - url: 'jorgesolla-test.vercel.app', - alias: aliasHosts, - readyState: 'READY', - }) - } - if (href.includes('/v2/deployments/') && href.includes('/aliases') && method === 'GET') { - return jsonResponse(true, 200, { - aliases: aliasHosts.map((alias) => ({ - alias, - uid: alias, - created: new Date().toISOString(), - })), - }) - } - if (href.includes('/v4/aliases') && method === 'GET') { - const assigned = aliasHosts.includes('pt.jorgesolla.com.br') - return jsonResponse(true, 200, { - aliases: assigned - ? [ - { - alias: 'pt.jorgesolla.com.br', - deploymentId: 'dpl_test', - uid: 'a1', - created: '', - projectId: 'prj_x', - }, - ] - : [], - }) - } - if (href.includes('/promote/') && method === 'POST') { - return jsonResponse(true, 200, {}) - } - if (href.includes('/aliases') && method === 'POST') { - aliasHosts = ['jorgesolla-solla.vercel.app', 'pt.jorgesolla.com.br'] - return jsonResponse(true, 200, { - alias: 'pt.jorgesolla.com.br', - uid: 'alias_1', - oldDeploymentId: 'dpl_old', - created: new Date().toISOString(), - }) - } - throw new Error(`unexpected fetch ${method} ${href}`) - }) as typeof fetch - - const result = await ensureProductionCustomDomain({ - token: 'tok', - projectId: 'prj_x', - teamId: 'team_x', - deploymentRef: 'https://jorgesolla-test.vercel.app', - fetchImpl, - sleepImpl: async () => {}, - }) - - expect(result.aliased).toBe(true) - expect(result.autoAssignWasFalse).toBe(true) - expect(result.clearedGitBranch).toBe(false) - expect(calls.some((c) => c.includes('/aliases') && c.startsWith('POST '))).toBe(true) - }) - - it('ensureProductionCustomDomain clears gitBranch before aliasing CLI deploys', async () => { - const calls: string[] = [] - let aliasHosts = ['jorgesolla-solla.vercel.app'] - let domainGitBranch: string | null = 'main' - const fetchImpl = (async (url: string | URL | Request, init?: RequestInit) => { - const href = String(url) - const method = init?.method ?? 'GET' - calls.push(`${method} ${href}`) - - if (href.includes('/v9/projects/') && href.includes('/domains') && method === 'GET') { - return jsonResponse(true, 200, { - domains: [ - { - name: 'pt.jorgesolla.com.br', - verified: true, - gitBranch: domainGitBranch, - }, - ], - }) - } - if ( - href.includes('/v9/projects/') && - href.includes('/domains/pt.jorgesolla.com.br') && - method === 'PATCH' - ) { - domainGitBranch = null - return jsonResponse(true, 200, { name: 'pt.jorgesolla.com.br', gitBranch: null }) - } - if (href.includes('/v9/projects/') && method === 'GET') { - return jsonResponse(true, 200, { autoAssignCustomDomains: true, name: 'jorgesolla' }) - } - if (href.includes('/v13/deployments/')) { - return jsonResponse(true, 200, { - id: 'dpl_test', - alias: aliasHosts, - readyState: 'READY', - }) - } - if (href.includes('/v2/deployments/') && href.includes('/aliases') && method === 'GET') { - return jsonResponse(true, 200, { - aliases: aliasHosts.map((alias) => ({ alias, uid: alias, created: '' })), - }) - } - if (href.includes('/v4/aliases') && method === 'GET') { - const assigned = aliasHosts.includes('pt.jorgesolla.com.br') - return jsonResponse(true, 200, { - aliases: assigned - ? [{ alias: 'pt.jorgesolla.com.br', deploymentId: 'dpl_test', uid: 'a1', created: '' }] - : [], - }) - } - if (href.includes('/promote/') && method === 'POST') { - return jsonResponse(true, 200, {}) - } - if (href.includes('/aliases') && method === 'POST') { - aliasHosts = ['jorgesolla-solla.vercel.app', 'pt.jorgesolla.com.br'] - return jsonResponse(true, 200, { - alias: 'pt.jorgesolla.com.br', - uid: 'alias_1', - created: new Date().toISOString(), - }) - } - throw new Error(`unexpected fetch ${method} ${href}`) - }) as typeof fetch - - const result = await ensureProductionCustomDomain({ - token: 'tok', - projectId: 'prj_x', - teamId: 'team_x', - deploymentRef: 'dpl_test', - fetchImpl, - sleepImpl: async () => {}, - }) - - expect(result.aliased).toBe(true) - expect(result.clearedGitBranch).toBe(true) - expect( - calls.some((c) => c.startsWith('PATCH ') && c.includes('/domains/pt.jorgesolla.com.br')), - ).toBe(true) - }) - - it('ensureProductionCustomDomain skips work when alias already present', async () => { - const fetchImpl = (async (url: string | URL | Request, init?: RequestInit) => { - const href = String(url) - const method = init?.method ?? 'GET' - if (href.includes('/v9/projects/') && href.includes('/domains') && method === 'GET') { - return jsonResponse(true, 200, { - domains: [{ name: 'pt.jorgesolla.com.br', verified: true }], - }) - } - if (href.includes('/v9/projects/') && method === 'GET') { - return jsonResponse(true, 200, { autoAssignCustomDomains: true }) - } - if (href.includes('/v13/deployments/')) { - return jsonResponse(true, 200, { - id: 'dpl_test', - alias: ['pt.jorgesolla.com.br'], - readyState: 'READY', - }) - } - if (href.includes('/v2/deployments/') && href.includes('/aliases') && method === 'GET') { - return jsonResponse(true, 200, { - aliases: [{ alias: 'pt.jorgesolla.com.br', uid: 'a1', created: '' }], - }) - } - if (href.includes('/v4/aliases') && method === 'GET') { - return jsonResponse(true, 200, { - aliases: [ - { - alias: 'pt.jorgesolla.com.br', - deploymentId: 'dpl_test', - uid: 'a1', - created: '', - projectId: 'prj_x', - }, - ], - }) - } - throw new Error(`unexpected fetch ${method} ${href}`) - }) as typeof fetch - - const result = await ensureProductionCustomDomain({ - token: 'tok', - projectId: 'prj_x', - teamId: 'team_x', - deploymentRef: 'dpl_test', - fetchImpl, - sleepImpl: async () => {}, - }) - - expect(result.alreadyAssigned).toBe(true) - }) - - it('ensureProductionCustomDomain fails when the hostname is not on the project', async () => { - const fetchImpl = (async (url: string | URL | Request, init?: RequestInit) => { - const href = String(url) - const method = init?.method ?? 'GET' - if (href.includes('/v9/projects/') && href.includes('/domains') && method === 'GET') { - return jsonResponse(true, 200, { domains: [{ name: 'other.example.com' }] }) - } - if (href.includes('/v9/projects/') && method === 'GET') { - return jsonResponse(true, 200, { autoAssignCustomDomains: true }) - } - throw new Error(`unexpected fetch ${method} ${href}`) - }) as typeof fetch - - await expect( - ensureProductionCustomDomain({ - token: 'tok', - projectId: 'prj_x', - teamId: 'team_x', - deploymentRef: 'dpl_test', - fetchImpl, - sleepImpl: async () => {}, - }), - ).rejects.toThrow(/not attached to this Vercel project/) - }) -}) diff --git a/vercel.json b/vercel.json deleted file mode 100644 index f29ec868..00000000 --- a/vercel.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "$schema": "https://openapi.vercel.sh/vercel.json", - "regions": ["gru1"], - "git": { - "deploymentEnabled": false - }, - "ignoreCommand": "bash scripts/vercel-ignore-build.sh" -} -- 2.45.2 From b5c97ec6ffefbdf668fd63487be8ac8624e2bec8 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Mon, 17 Aug 2026 13:25:17 -0300 Subject: [PATCH 2/2] fix(OPS50): prettier em ci.yml --- .forgejo/workflows/ci.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 718df720..bc3302e3 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -204,4 +204,3 @@ jobs: to_entries | all(.value.result == "success" or .value.result == "skipped") ' - -- 2.45.2