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:
commit
97f7423f81
5 changed files with 127 additions and 22 deletions
5
.github/workflows/ci.yml
vendored
5
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
5
.github/workflows/vercel-promote.yml
vendored
5
.github/workflows/vercel-promote.yml
vendored
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.`,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
Loading…
Reference in a new issue