Merge pull request #127 from fsolla/cursor/fix-vercel-alias-false-positive-1749

fix(ci) — stop trusting GET /v13 for production domain ownership
This commit is contained in:
github-actions[bot] 2026-08-01 16:23:36 +00:00 committed by GitHub
commit 97f7423f81
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 127 additions and 22 deletions

View file

@ -265,8 +265,9 @@ jobs:
# pt.jorgesolla.com.br on the previous Current — promote + verify here.
node scripts/vercel-ensure-production-alias.mjs "$deploy_url"
# Docs: promote staged production, then alias (Git Integration is OFF by design).
vercel promote "$deploy_url" --token="$VERCEL_TOKEN" --scope="$VERCEL_ORG_ID" --yes || true
vercel alias set "$deploy_url" pt.jorgesolla.com.br --token="$VERCEL_TOKEN" --scope="$VERCEL_ORG_ID" --yes
vercel promote "$deploy_url" --token="$VERCEL_TOKEN" --scope="$VERCEL_ORG_ID" || true
# `--yes` is only valid for `alias rm`, not `alias set` (CLI 55).
vercel alias set "$deploy_url" pt.jorgesolla.com.br --token="$VERCEL_TOKEN" --scope="$VERCEL_ORG_ID"
requeue:
runs-on: ubuntu-latest

View file

@ -67,5 +67,6 @@ jobs:
fi
node scripts/vercel-ensure-production-alias.mjs "$ref"
# Docs: when Auto-assign is/was OFF, prefer `vercel promote` then `alias set`.
vercel promote "$ref" --token="$VERCEL_TOKEN" --scope="$VERCEL_ORG_ID" --yes || true
vercel alias set "$ref" pt.jorgesolla.com.br --token="$VERCEL_TOKEN" --scope="$VERCEL_ORG_ID" --yes
vercel promote "$ref" --token="$VERCEL_TOKEN" --scope="$VERCEL_ORG_ID" || true
# `--yes` is only valid for `alias rm`, not `alias set` (CLI 55).
vercel alias set "$ref" pt.jorgesolla.com.br --token="$VERCEL_TOKEN" --scope="$VERCEL_ORG_ID"

View file

@ -83,7 +83,7 @@ Vercel Git builds: `scripts/vercel-ignore-build.sh` skipeia **todas** as branche
1. **Git / Branch tracking — não disparar build em push** — o que queremos OFF (já pinado em `vercel.json` `git.deploymentEnabled: false` + ignore script). Desligar “automatic deploy from main” no dashboard é o mesmo eixo — **não** confunda com o item 2.
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`.
3. **Domains → `pt.jorgesolla.com.br` → Git Branch** — deve ficar **vazio** (Production). Domínio com Git Branch só auto-alia via Git Integration; com Git deploy OFF + CLI `--prod`, o host fica preso no Current antigo. O step pós-deploy `scripts/vercel-ensure-production-alias.mjs` reativa auto-assign, **limpa gitBranch**, faz `promote` + `POST /v2/deployments/{id}/aliases` + `vercel alias set`. Emergência sem rebuild: workflow `Vercel promote production`.
3. **Domains → `pt.jorgesolla.com.br` → Git Branch** — deve ficar **vazio** (Production). Domínio com Git Branch só auto-alia via Git Integration; com Git deploy OFF + CLI `--prod`, o host fica preso no Current antigo. O step pós-deploy `scripts/vercel-ensure-production-alias.mjs` reativa auto-assign, **limpa gitBranch**, faz `promote` + `POST /v2/deployments/{id}/aliases` + `vercel alias set`, e **não** confia no campo `alias` de `GET /v13` (pode listar o hostname de produção sem o domínio estar nesse deployment). Emergência sem rebuild: workflow `Vercel promote production`.
## Cursor Cloud

View file

@ -35,6 +35,23 @@ export const aliasesIncludeHost = (aliases, expectedHost) => {
return aliases.some((alias) => normalizeHostname(alias) === want)
}
/**
* Vercel deployment ids appear as `dpl_…` or bare ids depending on the endpoint.
* @param {string | null | undefined} a
* @param {string | null | undefined} b
*/
export const sameDeploymentId = (a, b) => {
const left = String(a ?? '')
.trim()
.replace(/^dpl_/i, '')
.toLowerCase()
const right = String(b ?? '')
.trim()
.replace(/^dpl_/i, '')
.toLowerCase()
return Boolean(left) && left === right
}
/**
* Normalize a CLI stdout URL / inspect path / bare id into something the
* deployments GET endpoint accepts (`dpl_…`, short id, or hostname).
@ -610,17 +627,31 @@ export const ensureProductionCustomDomain = async ({
deploymentId: deployment.id,
fetchImpl,
})
if (aliasesIncludeHost(hosts, expectedHost)) return true
if (aliasesIncludeHost(hosts, expectedHost)) {
return { onDeployment: true, hosts, domainTarget: null }
}
const domainTarget = await fetchDomainAliasTarget({
token,
teamId,
domain: expectedHost,
fetchImpl,
})
return domainTarget?.deploymentId === deployment.id
return {
onDeployment: sameDeploymentId(domainTarget?.deploymentId, deployment.id),
hosts,
domainTarget,
}
}
if (aliasesIncludeHost(deployment.aliases, expectedHost) || (await hostAlreadyOnDeployment())) {
// Do NOT trust GET /v13 `alias` — it can list the production hostname on a
// fresh --prod deploy even when the custom domain still points at an older
// Current (observed: ensure short-circuited and left pt.jorgesolla.com.br stale).
const ownership = await hostAlreadyOnDeployment()
note(
`domain ${expectedHost} → ${ownership.domainTarget?.deploymentId ?? '(via deployment aliases)'} ` +
`(this=${deployment.id}; listed=${JSON.stringify(ownership.hosts)})`,
)
if (ownership.onDeployment) {
note(`already aliased to ${expectedHost}`)
return {
alreadyAssigned: true,
@ -632,6 +663,11 @@ export const ensureProductionCustomDomain = async ({
steps,
}
}
if (ownership.domainTarget?.deploymentId) {
note(
`${expectedHost} still on ${ownership.domainTarget.deploymentId} — will promote/alias onto ${deployment.id}`,
)
}
let promoted = false
try {
@ -673,7 +709,8 @@ export const ensureProductionCustomDomain = async ({
let verified = false
for (let attempt = 0; attempt < 10; attempt += 1) {
if (await hostAlreadyOnDeployment()) {
const check = await hostAlreadyOnDeployment()
if (check.onDeployment) {
verified = true
break
}
@ -681,21 +718,10 @@ export const ensureProductionCustomDomain = async ({
}
if (!verified) {
const hosts = await listDeploymentAliasHosts({
token,
teamId,
deploymentId: deployment.id,
fetchImpl,
})
const domainTarget = await fetchDomainAliasTarget({
token,
teamId,
domain: expectedHost,
fetchImpl,
})
const check = await hostAlreadyOnDeployment()
throw new Error(
`After alias assign, ${expectedHost} is still not on deployment ${deployment.id}. ` +
`deployment aliases=${JSON.stringify(hosts)}; domain points to=${JSON.stringify(domainTarget)}; ` +
`deployment aliases=${JSON.stringify(check.hosts)}; domain points to=${JSON.stringify(check.domainTarget)}; ` +
`aliasApiBody=${JSON.stringify(assignResult.body)}. ` +
`Check Vercel → Settings → Domains: Git Branch must be empty, and Environments → Production → Auto-assign Custom Production Domains must be ON.`,
)

View file

@ -8,6 +8,7 @@ import {
normalizeHostname,
parseDeploymentRef,
PRODUCTION_CUSTOM_DOMAIN,
sameDeploymentId,
} from '../../scripts/lib/vercel-production-alias.mjs'
const jsonResponse = (ok: boolean, status: number, payload: unknown) =>
@ -36,6 +37,82 @@ describe('vercelProductionAlias', () => {
expect(aliasesIncludeHost(['jorgesolla-solla.vercel.app'], 'pt.jorgesolla.com.br')).toBe(false)
})
it('sameDeploymentId ignores dpl_ prefix', () => {
expect(sameDeploymentId('dpl_abc', 'abc')).toBe(true)
expect(sameDeploymentId('dpl_abc', 'dpl_abc')).toBe(true)
expect(sameDeploymentId('dpl_abc', 'dpl_other')).toBe(false)
})
it('ensureProductionCustomDomain does not trust GET /v13 alias alone', async () => {
// v13 lists the custom host (false positive observed in CI) while the domain
// alias target still points at an older deployment — must promote/alias.
let aliasHosts = ['jorgesolla-solla.vercel.app']
let domainTargetId = 'dpl_old'
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', verified: true }],
})
}
if (href.includes('/v9/projects/') && method === 'GET') {
return jsonResponse(true, 200, { autoAssignCustomDomains: true })
}
if (href.includes('/v13/deployments/')) {
return jsonResponse(true, 200, {
id: 'dpl_new',
// Misleading: listed even though domain still points at dpl_old.
alias: ['pt.jorgesolla.com.br', 'jorgesolla-solla.vercel.app'],
readyState: 'READY',
})
}
if (href.includes('/v2/deployments/') && href.includes('/aliases') && method === 'GET') {
return jsonResponse(true, 200, {
aliases: aliasHosts.map((alias) => ({ alias, uid: alias, created: '' })),
})
}
if (href.includes('/v4/aliases') && method === 'GET') {
return jsonResponse(true, 200, {
aliases: [
{
alias: 'pt.jorgesolla.com.br',
deploymentId: domainTargetId,
uid: 'a1',
created: '',
},
],
})
}
if (href.includes('/promote/') && method === 'POST') {
return jsonResponse(true, 200, {})
}
if (href.includes('/aliases') && method === 'POST') {
aliasHosts = ['jorgesolla-solla.vercel.app', 'pt.jorgesolla.com.br']
domainTargetId = 'dpl_new'
return jsonResponse(true, 200, {
alias: 'pt.jorgesolla.com.br',
uid: 'alias_1',
oldDeploymentId: 'dpl_old',
created: new Date().toISOString(),
})
}
throw new Error(`unexpected fetch ${method} ${href}`)
}) as typeof fetch
const result = await ensureProductionCustomDomain({
token: 'tok',
projectId: 'prj_x',
teamId: 'team_x',
deploymentRef: 'dpl_new',
fetchImpl,
sleepImpl: async () => {},
})
expect(result.alreadyAssigned).toBe(false)
expect(result.aliased).toBe(true)
})
it('parseDeploymentRef accepts inspect paths and vercel.app hosts', () => {
expect(
parseDeploymentRef('https://vercel.com/solla/jorgesolla/CQkmCejZSvRoFtYP8hHbjzP3pc1L'),