Merge pull request #125 from fsolla/cursor/fix-vercel-alias-verify-1749

fix(ci) — verify + vercel alias set for production domain
This commit is contained in:
github-actions[bot] 2026-08-01 15:57:45 +00:00 committed by GitHub
commit 1ae3ab414b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 204 additions and 47 deletions

View file

@ -264,6 +264,8 @@ jobs:
# 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"
# CLI alias when Git Integration is disabled (Vercel docs).
vercel alias set "$deploy_url" pt.jorgesolla.com.br --token="$VERCEL_TOKEN" --scope="$VERCEL_ORG_ID" --yes
requeue:
runs-on: ubuntu-latest

View file

@ -40,13 +40,20 @@ jobs:
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
node scripts/vercel-ensure-production-alias.mjs "$DEPLOYMENT_REF"
ref="$DEPLOYMENT_REF"
node scripts/vercel-ensure-production-alias.mjs "$ref"
else
node scripts/vercel-ensure-production-alias.mjs --latest
fi
# Belt-and-suspenders: CLI alias set (docs recommend this when Git Integration is off).
if [ -n "${DEPLOYMENT_REF:-}" ]; then
vercel alias set "$DEPLOYMENT_REF" pt.jorgesolla.com.br --token="$VERCEL_TOKEN" --scope="$VERCEL_ORG_ID" --yes
fi

View file

@ -249,15 +249,93 @@ const assignDeploymentAlias = async ({ token, teamId, deploymentId, alias, fetch
} catch {
body = { raw: bodyText }
}
// 409 = already assigned to this deployment — success for our purposes.
if (response.ok || response.status === 409) return body
if (response.ok) return { ok: true, body }
const message =
typeof body?.error?.message === 'string'
? body.error.message
: bodyText || `HTTP ${response.status}`
if (response.status === 409 && /already assigned to (the )?given deployment/i.test(message)) {
return { ok: true, body, 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
@ -360,6 +438,7 @@ export const fetchLatestProductionReadyDeployment = async ({
* expectedHost?: string
* fetchImpl?: typeof fetch
* sleepImpl?: (ms: number) => Promise<void>
* onStep?: (message: string) => void
* }} options
*/
export const ensureProductionCustomDomain = async ({
@ -370,13 +449,19 @@ export const ensureProductionCustomDomain = async ({
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
const steps = []
if (autoAssign === false) {
steps.push('autoAssignCustomDomains was false — enabling (Git OFF ≠ domain assign OFF)')
note('autoAssignCustomDomains was false — enabling (Git OFF ≠ domain assign OFF)')
await setAutoAssignCustomDomains({
token,
projectId,
@ -385,18 +470,18 @@ export const ensureProductionCustomDomain = async ({
fetchImpl,
})
} else {
steps.push(`autoAssignCustomDomains=${String(autoAssign)}`)
note(`autoAssignCustomDomains=${String(autoAssign)}`)
}
const projectDomains = await fetchProjectDomains({ token, projectId, teamId, fetchImpl })
steps.push(
note(
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.`,
`${expectedHost} is not attached to this Vercel project. Add it under Project → Settings → Domains, then re-run. Domains seen: ${JSON.stringify(projectDomains)}`,
)
}
@ -406,9 +491,27 @@ export const ensureProductionCustomDomain = async ({
deploymentIdOrUrl: deploymentRef,
fetchImpl,
})
note(`deployment ${deployment.id} readyState=${String(deployment.readyState)}`)
if (aliasesIncludeHost(deployment.aliases, expectedHost)) {
steps.push(`already aliased to ${expectedHost}`)
const hostAlreadyOnDeployment = async () => {
const hosts = await listDeploymentAliasHosts({
token,
teamId,
deploymentId: deployment.id,
fetchImpl,
})
if (aliasesIncludeHost(hosts, expectedHost)) return true
const domainTarget = await fetchDomainAliasTarget({
token,
teamId,
domain: expectedHost,
fetchImpl,
})
return domainTarget?.deploymentId === deployment.id
}
if (aliasesIncludeHost(deployment.aliases, expectedHost) || (await hostAlreadyOnDeployment())) {
note(`already aliased to ${expectedHost}`)
return {
alreadyAssigned: true,
promoted: false,
@ -419,10 +522,8 @@ export const ensureProductionCustomDomain = async ({
}
}
// 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}`)
note(`promoting ${deployment.id}`)
await promoteProductionDeployment({
token,
projectId,
@ -431,43 +532,58 @@ export const ensureProductionCustomDomain = async ({
fetchImpl,
})
} catch (error) {
steps.push(`promote skipped: ${error instanceof Error ? error.message : String(error)}`)
note(`promote skipped: ${error instanceof Error ? error.message : String(error)}`)
}
steps.push(`assigning alias ${expectedHost} → ${deployment.id}`)
await assignDeploymentAlias({
note(`assigning alias ${expectedHost} → ${deployment.id}`)
const assignResult = await assignDeploymentAlias({
token,
teamId,
deploymentId: deployment.id,
alias: normalizeHostname(expectedHost),
fetchImpl,
})
note(
assignResult.already
? `alias API: already on this deployment`
: `alias API: assigned ${expectedHost}`,
)
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
let verified = false
for (let attempt = 0; attempt < 10; attempt += 1) {
if (await hostAlreadyOnDeployment()) {
verified = true
break
}
await sleepImpl(1500)
}
if (!aliasesIncludeHost(verified.aliases, expectedHost)) {
if (!verified) {
const hosts = await listDeploymentAliasHosts({
token,
teamId,
deploymentId: deployment.id,
fetchImpl,
})
const domainTarget = await fetchDomainAliasTarget({
token,
teamId,
domain: expectedHost,
fetchImpl,
})
throw new Error(
`After alias assign, ${expectedHost} is still not on deployment ${deployment.id}. Aliases: ${JSON.stringify(verified.aliases)}`,
`After alias assign, ${expectedHost} is still not on deployment ${deployment.id}. ` +
`deployment aliases=${JSON.stringify(hosts)}; domain points to=${JSON.stringify(domainTarget)}`,
)
}
steps.push(`verified alias ${expectedHost}`)
note(`verified alias ${expectedHost}`)
return {
alreadyAssigned: false,
promoted: true,
aliased: true,
autoAssignWasFalse: autoAssign === false,
deployment: verified,
deployment,
steps,
}
}

View file

@ -45,15 +45,12 @@ const main = async () => {
teamId,
deploymentRef,
expectedHost: PRODUCTION_CUSTOM_DOMAIN,
onStep: (message) => console.log(`[vercel-ensure-production-alias] ${message}`),
})
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)'),
(result.aliased ? ' (aliased)' : result.alreadyAssigned ? ' (already assigned)' : ''),
)
}

View file

@ -52,7 +52,7 @@ describe('vercelProductionAlias', () => {
it('ensureProductionCustomDomain enables auto-assign, promotes, and aliases explicitly', async () => {
const calls: string[] = []
let aliases = ['jorgesolla-solla.vercel.app']
let aliasHosts = ['jorgesolla-solla.vercel.app']
const fetchImpl = (async (url: string | URL | Request, init?: RequestInit) => {
const href = String(url)
const method = init?.method ?? 'GET'
@ -65,27 +65,48 @@ describe('vercelProductionAlias', () => {
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,
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') {
const body = JSON.parse(String(init?.body ?? '{}'))
expect(body.alias).toBe('pt.jorgesolla.com.br')
aliases = ['jorgesolla-solla.vercel.app', 'pt.jorgesolla.com.br']
aliasHosts = ['jorgesolla-solla.vercel.app', 'pt.jorgesolla.com.br']
return jsonResponse(true, 200, {
alias: body.alias,
alias: 'pt.jorgesolla.com.br',
uid: 'alias_1',
created: new Date().toISOString(),
})
@ -104,10 +125,7 @@ describe('vercelProductionAlias', () => {
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)
expect(calls.some((c) => c.includes('/aliases') && c.startsWith('POST '))).toBe(true)
})
it('ensureProductionCustomDomain skips work when alias already present', async () => {
@ -127,6 +145,24 @@ describe('vercelProductionAlias', () => {
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
@ -139,7 +175,6 @@ describe('vercelProductionAlias', () => {
sleepImpl: async () => {},
})
expect(result.promoted).toBe(false)
expect(result.alreadyAssigned).toBe(true)
})