From baa8f14f825c56f3e1d59a1dcb9aaec6d337c68d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 1 Aug 2026 15:25:28 +0000 Subject: [PATCH] fix(ci): promote Vercel deploy to pt.jorgesolla.com.br MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Disabling Git auto-deploy is correct for Actions-gated production. The separate project toggle "Auto-assign Custom Production Domains" must stay ON — when OFF, `vercel deploy --prod` only aliases *.vercel.app and leaves the custom domain on an older Current (exactly what we saw after #122). After each deploy, ensure-production-alias re-enables auto-assign if needed, promotes the deployment, and verifies pt.jorgesolla.com.br is attached. Also adds workflow_dispatch "Vercel promote production" for promote-without-rebuild. --- .github/workflows/ci.yml | 9 +- .github/workflows/vercel-promote.yml | 52 +++ docs/AGENT-OPS.md | 5 + docs/CUTOVER-MAIN-ONLY.md | 2 + scripts/lib/vercel-production-alias.mjs | 368 ++++++++++++++++++ scripts/vercel-ensure-production-alias.mjs | 62 +++ tests/unit/vercelProductionAlias.unit.spec.ts | 129 ++++++ 7 files changed, 626 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/vercel-promote.yml create mode 100644 scripts/lib/vercel-production-alias.mjs create mode 100644 scripts/vercel-ensure-production-alias.mjs create mode 100644 tests/unit/vercelProductionAlias.unit.spec.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 56573a0f..30aaf93b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -256,7 +256,14 @@ jobs: # "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" - vercel deploy --prebuilt --prod --archive=tgz --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" requeue: runs-on: ubuntu-latest diff --git a/.github/workflows/vercel-promote.yml b/.github/workflows/vercel-promote.yml new file mode 100644 index 00000000..34f3f318 --- /dev/null +++ b/.github/workflows/vercel-promote.yml @@ -0,0 +1,52 @@ +# 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: '' + +permissions: + contents: read + +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: Promote to custom production domain + env: + DEPLOYMENT_REF: ${{ inputs.deployment }} + run: | + set -euo pipefail + if [ -n "${DEPLOYMENT_REF:-}" ]; then + node scripts/vercel-ensure-production-alias.mjs "$DEPLOYMENT_REF" + else + node scripts/vercel-ensure-production-alias.mjs --latest + fi diff --git a/docs/AGENT-OPS.md b/docs/AGENT-OPS.md index 991ef501..c2cd155d 100644 --- a/docs/AGENT-OPS.md +++ b/docs/AGENT-OPS.md @@ -79,6 +79,11 @@ Fast gate: `pnpm gate:fast`. Push: `pnpm push`. 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). +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`. O step pós-deploy `scripts/vercel-ensure-production-alias.mjs` reativa o flag e faz `promote`; emergência sem rebuild: workflow `Vercel promote production`. + ## Cursor Cloud `.cursor/environment.json` instala deps + Postgres nativo + `db:seed:minimal`. Sem secrets de prod. Preferir `pnpm push`; escape `git push --no-verify` só no cutover documentado acima. diff --git a/docs/CUTOVER-MAIN-ONLY.md b/docs/CUTOVER-MAIN-ONLY.md index f04a4d51..8e8ef8db 100644 --- a/docs/CUTOVER-MAIN-ONLY.md +++ b/docs/CUTOVER-MAIN-ONLY.md @@ -8,6 +8,8 @@ Completed 2026-08-01. Branch `stage` and GitHub Environment `stage` are deleted. - [x] Repo secrets present (`VERCEL_*`, `POOL_GITHUB_TOKEN`) - [x] `VERCEL_ORG_ID` / `VERCEL_PROJECT_ID` synced from local `.vercel/project.json` (`vercel link`) - [x] `ci.yml` deploy job green (`vercel@55`, Node `24.x`, alias https://pt.jorgesolla.com.br) +- [x] **Git auto-deploy OFF** (`vercel.json` `git.deploymentEnabled: false` + ignore script) — correct for Actions-gated deploys +- [x] **Auto-assign Custom Production Domains ON** (Project → Settings → Environments → Production → Branch Tracking). Turning this OFF stages CLI `--prod` deploys without moving `pt.jorgesolla.com.br`; CI now re-enables + `promote`s via `scripts/vercel-ensure-production-alias.mjs`. Emergency: Actions → "Vercel promote production". - [x] Branch `stage` deleted - [x] GitHub Environment `stage` (and `STAGE_*` secrets) deleted - [x] Dead stage tooling removed (`db:refresh:stage`, `agent:promote`) diff --git a/scripts/lib/vercel-production-alias.mjs b/scripts/lib/vercel-production-alias.mjs new file mode 100644 index 00000000..b0a5f4b9 --- /dev/null +++ b/scripts/lib/vercel-production-alias.mjs @@ -0,0 +1,368 @@ +/** + * 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) +} + +/** + * 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 +} + +/** + * @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 + * }} options + */ +export const ensureProductionCustomDomain = async ({ + token, + projectId, + teamId, + deploymentRef, + expectedHost = PRODUCTION_CUSTOM_DOMAIN, + fetchImpl = fetch, + sleepImpl = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), +}) => { + const project = await fetchVercelProject({ token, projectId, teamId, fetchImpl }) + const autoAssign = project.autoAssignCustomDomains + const steps = [] + + if (autoAssign === false) { + steps.push('autoAssignCustomDomains was false — enabling (Git OFF ≠ domain assign OFF)') + await setAutoAssignCustomDomains({ + token, + projectId, + teamId, + enabled: true, + fetchImpl, + }) + } else { + steps.push(`autoAssignCustomDomains=${String(autoAssign)}`) + } + + const deployment = await fetchDeploymentAliases({ + token, + teamId, + deploymentIdOrUrl: deploymentRef, + fetchImpl, + }) + + if (aliasesIncludeHost(deployment.aliases, expectedHost)) { + steps.push(`already aliased to ${expectedHost}`) + return { + alreadyAssigned: true, + promoted: false, + autoAssignWasFalse: autoAssign === false, + deployment, + steps, + } + } + + steps.push(`promoting ${deployment.id} so ${expectedHost} points at this deployment`) + await promoteProductionDeployment({ + token, + projectId, + teamId, + deploymentId: deployment.id, + fetchImpl, + }) + + // Promote is async on some accounts — poll briefly for the alias. + let verified = deployment + for (let attempt = 0; attempt < 8; attempt += 1) { + verified = await fetchDeploymentAliases({ + token, + teamId, + deploymentIdOrUrl: deployment.id, + fetchImpl, + }) + if (aliasesIncludeHost(verified.aliases, expectedHost)) break + await sleepImpl(1500) + } + + if (!aliasesIncludeHost(verified.aliases, expectedHost)) { + throw new Error( + `After promote, ${expectedHost} is still not on deployment ${deployment.id}. Aliases: ${JSON.stringify(verified.aliases)}`, + ) + } + + steps.push(`verified alias ${expectedHost}`) + return { + alreadyAssigned: false, + promoted: true, + autoAssignWasFalse: autoAssign === false, + deployment: verified, + steps, + } +} diff --git a/scripts/vercel-ensure-production-alias.mjs b/scripts/vercel-ensure-production-alias.mjs new file mode 100644 index 00000000..b6536aeb --- /dev/null +++ b/scripts/vercel-ensure-production-alias.mjs @@ -0,0 +1,62 @@ +#!/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, + }) + + for (const step of result.steps) { + console.log(`[vercel-ensure-production-alias] ${step}`) + } + + console.log( + `[vercel-ensure-production-alias] ok — ${PRODUCTION_CUSTOM_DOMAIN} → ${result.deployment.id}` + + (result.promoted ? ' (promoted)' : ' (already assigned)'), + ) +} + +main().catch((error) => { + die(error instanceof Error ? error.message : String(error)) +}) diff --git a/tests/unit/vercelProductionAlias.unit.spec.ts b/tests/unit/vercelProductionAlias.unit.spec.ts new file mode 100644 index 00000000..6ecd4f83 --- /dev/null +++ b/tests/unit/vercelProductionAlias.unit.spec.ts @@ -0,0 +1,129 @@ +// @vitest-environment node + +import { describe, expect, it } from 'vitest' + +import { + aliasesIncludeHost, + ensureProductionCustomDomain, + normalizeHostname, + parseDeploymentRef, + PRODUCTION_CUSTOM_DOMAIN, +} 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('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 and promotes when domain missing', async () => { + const calls: string[] = [] + let aliases = ['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/') && method === 'GET') { + return jsonResponse(true, 200, { autoAssignCustomDomains: false, name: 'jorgesolla' }) + } + if (href.includes('/v9/projects/') && method === 'PATCH') { + const body = JSON.parse(String(init?.body ?? '{}')) + expect(body.autoAssignCustomDomains).toBe(true) + return jsonResponse(true, 200, { autoAssignCustomDomains: true }) + } + if (href.includes('/v13/deployments/')) { + return jsonResponse(true, 200, { + id: 'dpl_test', + url: 'jorgesolla-test.vercel.app', + alias: aliases, + readyState: 'READY', + }) + } + if (href.includes('/promote/') && method === 'POST') { + aliases = ['jorgesolla-solla.vercel.app', 'pt.jorgesolla.com.br'] + return jsonResponse(true, 200, {}) + } + 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.promoted).toBe(true) + expect(result.autoAssignWasFalse).toBe(true) + expect(result.alreadyAssigned).toBe(false) + expect(calls.some((c) => c.startsWith('PATCH '))).toBe(true) + expect(calls.some((c) => c.includes('/promote/'))).toBe(true) + }) + + it('ensureProductionCustomDomain skips promote 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/') && 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', + }) + } + 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.promoted).toBe(false) + expect(result.alreadyAssigned).toBe(true) + }) +})