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.
This commit is contained in:
Cursor Agent 2026-08-01 15:37:16 +00:00
parent e1671d3e5d
commit c59b3fe31d
No known key found for this signature in database
3 changed files with 156 additions and 10 deletions

View file

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

View file

@ -181,6 +181,83 @@ const promoteProductionDeployment = async ({
return body
}
/**
* @param {{
* token: string
* projectId: string
* teamId: string
* fetchImpl?: typeof fetch
* }} options
* @returns {Promise<string[]>}
*/
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,

View file

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