Merge pull request #124 from fsolla/cursor/fix-vercel-explicit-alias-1749
fix(ci) — explicit alias for pt.jorgesolla.com.br after promote
This commit is contained in:
commit
d9cc0bc6e3
3 changed files with 156 additions and 10 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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/)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue