From c59b3fe31d910738df25e1cc9de7dbf5071c99cb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 1 Aug 2026 15:37:16 +0000 Subject: [PATCH] fix(ci): explicitly alias pt.jorgesolla.com.br after Vercel promote Promote alone left the staged deployment on *.vercel.app only. Assign the custom hostname via POST /v2/deployments/{id}/aliases after verifying it is attached to the project domains list. --- docs/AGENT-OPS.md | 2 +- scripts/lib/vercel-production-alias.mjs | 115 +++++++++++++++++- tests/unit/vercelProductionAlias.unit.spec.ts | 49 +++++++- 3 files changed, 156 insertions(+), 10 deletions(-) diff --git a/docs/AGENT-OPS.md b/docs/AGENT-OPS.md index c2cd155d..d4188237 100644 --- a/docs/AGENT-OPS.md +++ b/docs/AGENT-OPS.md @@ -82,7 +82,7 @@ Vercel Git builds: `scripts/vercel-ignore-build.sh` skipeia **todas** as branche **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`. +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`. Mesmo com ON, domínios amarrados só ao Git Integration podem exigir alias explícito — o step pós-deploy `scripts/vercel-ensure-production-alias.mjs` reativa o flag, faz `promote` e `POST /v2/deployments/{id}/aliases`. Emergência sem rebuild: workflow `Vercel promote production`. ## Cursor Cloud diff --git a/scripts/lib/vercel-production-alias.mjs b/scripts/lib/vercel-production-alias.mjs index b0a5f4b9..e9bc690f 100644 --- a/scripts/lib/vercel-production-alias.mjs +++ b/scripts/lib/vercel-production-alias.mjs @@ -181,6 +181,83 @@ const promoteProductionDeployment = async ({ return body } +/** + * @param {{ + * token: string + * projectId: string + * teamId: string + * fetchImpl?: typeof fetch + * }} options + * @returns {Promise} + */ +const fetchProjectDomains = async ({ token, projectId, teamId, fetchImpl = fetch }) => { + const params = new URLSearchParams({ teamId }) + const response = await fetchImpl( + `${VERCEL_API_BASE}/v9/projects/${projectId}/domains?${params}`, + { + headers: { authorization: `Bearer ${token}` }, + }, + ) + const bodyText = await response.text() + let body + try { + body = bodyText ? JSON.parse(bodyText) : {} + } catch { + throw new Error(`Vercel project domains API returned non-JSON (${response.status})`) + } + if (!response.ok) { + const message = + typeof body?.error?.message === 'string' + ? body.error.message + : bodyText || `HTTP ${response.status}` + throw new Error(`Vercel project domains API ${response.status}: ${message}`) + } + const rows = Array.isArray(body.domains) ? body.domains : Array.isArray(body) ? body : [] + return rows + .map((row) => (row && typeof row.name === 'string' ? normalizeHostname(row.name) : '')) + .filter(Boolean) +} + +/** + * 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 } + } + // 409 = already assigned to this deployment — success for our purposes. + if (response.ok || response.status === 409) return body + const message = + typeof body?.error?.message === 'string' + ? body.error.message + : bodyText || `HTTP ${response.status}` + throw new Error(`Vercel alias API ${response.status}: ${message}`) +} + /** * @param {{ * token: string @@ -311,6 +388,18 @@ export const ensureProductionCustomDomain = async ({ steps.push(`autoAssignCustomDomains=${String(autoAssign)}`) } + const projectDomains = await fetchProjectDomains({ token, projectId, teamId, fetchImpl }) + steps.push( + projectDomains.length > 0 + ? `project domains: ${projectDomains.join(', ')}` + : 'project domains: (none)', + ) + if (!projectDomains.includes(normalizeHostname(expectedHost))) { + throw new Error( + `${expectedHost} is not attached to this Vercel project. Add it under Project → Settings → Domains, then re-run.`, + ) + } + const deployment = await fetchDeploymentAliases({ token, teamId, @@ -323,22 +412,37 @@ export const ensureProductionCustomDomain = async ({ return { alreadyAssigned: true, promoted: false, + aliased: false, autoAssignWasFalse: autoAssign === false, deployment, steps, } } - steps.push(`promoting ${deployment.id} so ${expectedHost} points at this deployment`) - await promoteProductionDeployment({ + // Best-effort promote (marks Current). CLI deploys often still need an explicit alias + // for custom hostnames that only auto-apply via Git Integration. + try { + steps.push(`promoting ${deployment.id}`) + await promoteProductionDeployment({ + token, + projectId, + teamId, + deploymentId: deployment.id, + fetchImpl, + }) + } catch (error) { + steps.push(`promote skipped: ${error instanceof Error ? error.message : String(error)}`) + } + + steps.push(`assigning alias ${expectedHost} → ${deployment.id}`) + await assignDeploymentAlias({ token, - projectId, teamId, deploymentId: deployment.id, + alias: normalizeHostname(expectedHost), 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({ @@ -353,7 +457,7 @@ export const ensureProductionCustomDomain = async ({ if (!aliasesIncludeHost(verified.aliases, expectedHost)) { throw new Error( - `After promote, ${expectedHost} is still not on deployment ${deployment.id}. Aliases: ${JSON.stringify(verified.aliases)}`, + `After alias assign, ${expectedHost} is still not on deployment ${deployment.id}. Aliases: ${JSON.stringify(verified.aliases)}`, ) } @@ -361,6 +465,7 @@ export const ensureProductionCustomDomain = async ({ return { alreadyAssigned: false, promoted: true, + aliased: true, autoAssignWasFalse: autoAssign === false, deployment: verified, steps, diff --git a/tests/unit/vercelProductionAlias.unit.spec.ts b/tests/unit/vercelProductionAlias.unit.spec.ts index 6ecd4f83..b9f8b7c5 100644 --- a/tests/unit/vercelProductionAlias.unit.spec.ts +++ b/tests/unit/vercelProductionAlias.unit.spec.ts @@ -50,7 +50,7 @@ describe('vercelProductionAlias', () => { expect(parseDeploymentRef('dpl_abc123')).toEqual({ kind: 'id', value: 'dpl_abc123' }) }) - it('ensureProductionCustomDomain enables auto-assign and promotes when domain missing', async () => { + it('ensureProductionCustomDomain enables auto-assign, promotes, and aliases explicitly', async () => { const calls: string[] = [] let aliases = ['jorgesolla-solla.vercel.app'] const fetchImpl = (async (url: string | URL | Request, init?: RequestInit) => { @@ -58,6 +58,9 @@ describe('vercelProductionAlias', () => { 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' }] }) + } if (href.includes('/v9/projects/') && method === 'GET') { return jsonResponse(true, 200, { autoAssignCustomDomains: false, name: 'jorgesolla' }) } @@ -75,9 +78,18 @@ describe('vercelProductionAlias', () => { }) } if (href.includes('/promote/') && method === 'POST') { - aliases = ['jorgesolla-solla.vercel.app', 'pt.jorgesolla.com.br'] return jsonResponse(true, 200, {}) } + if (href.includes('/aliases') && method === 'POST') { + const body = JSON.parse(String(init?.body ?? '{}')) + expect(body.alias).toBe('pt.jorgesolla.com.br') + aliases = ['jorgesolla-solla.vercel.app', 'pt.jorgesolla.com.br'] + return jsonResponse(true, 200, { + alias: body.alias, + uid: 'alias_1', + created: new Date().toISOString(), + }) + } throw new Error(`unexpected fetch ${method} ${href}`) }) as typeof fetch @@ -90,17 +102,21 @@ describe('vercelProductionAlias', () => { sleepImpl: async () => {}, }) - expect(result.promoted).toBe(true) + expect(result.aliased).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) + expect(calls.some((c) => c.includes('/aliases'))).toBe(true) }) - it('ensureProductionCustomDomain skips promote when alias already present', async () => { + 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' }] }) + } if (href.includes('/v9/projects/') && method === 'GET') { return jsonResponse(true, 200, { autoAssignCustomDomains: true }) } @@ -126,4 +142,29 @@ describe('vercelProductionAlias', () => { expect(result.promoted).toBe(false) 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/) + }) })