ops(OPS50): remove deploy Vercel do pipeline (produção é self-hosted no homeserver) #7

Merged
fsolla merged 2 commits from OPS50-remove-vercel-deploy into main 2026-08-17 16:33:43 +00:00
13 changed files with 20 additions and 1743 deletions

View file

@ -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 <PR> --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)

View file

@ -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
@ -205,152 +204,3 @@ jobs:
to_entries
| 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"

View file

@ -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"

View file

@ -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 <name>` → 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 <name>` → 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://<prod-domain>/api/revalidate?tag=global_privacy-policy" \
```
- **Auth:** send the secret via `x-revalidate-secret` or `Authorization: Bearer <secret>`; 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 <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.
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.

View file

@ -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

View file

@ -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<number | null>}
*/
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,
}
}

View file

@ -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<ProjectDomainRow[]>}
*/
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<string[]>}
*/
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<void>
* 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,
}
}

View file

@ -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=<residual>
* last_production_deploy_at=<ms epoch or empty>
*/
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))
})

View file

@ -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 <deployment-url-or-id>
* 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))
})

View file

@ -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

View file

@ -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/)
})
})

View file

@ -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/)
})
})

View file

@ -1,8 +0,0 @@
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"regions": ["gru1"],
"git": {
"deploymentEnabled": false
},
"ignoreCommand": "bash scripts/vercel-ignore-build.sh"
}