Merge pull request #126 from fsolla/cursor/fix-vercel-domain-gitbranch-1749

fix(ci) — clear domain gitBranch so CLI prod aliases work
This commit is contained in:
Francisco Solla 2026-08-01 13:09:57 -03:00 committed by GitHub
commit b5303bd8d6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 252 additions and 25 deletions

View file

@ -264,7 +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).
# 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
requeue:

View file

@ -49,11 +49,23 @@ jobs:
set -euo pipefail
if [ -n "${DEPLOYMENT_REF:-}" ]; then
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
ref=$(node --input-type=module -e '
import { fetchLatestProductionReadyDeployment } from "./scripts/lib/vercel-production-alias.mjs";
const d = await fetchLatestProductionReadyDeployment({
token: process.env.VERCEL_TOKEN,
projectId: process.env.VERCEL_PROJECT_ID,
teamId: process.env.VERCEL_ORG_ID,
});
if (!d) {
console.error("no READY production deployment");
process.exit(1);
}
process.stdout.write(d.url ? `https://${d.url}` : d.id);
')
echo "Latest READY production: $ref"
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

View file

@ -81,8 +81,9 @@ 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`. 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`.
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`.
## Cursor Cloud

View file

@ -9,7 +9,8 @@ Completed 2026-08-01. Branch `stage` and GitHub Environment `stage` are deleted.
- [x] `VERCEL_ORG_ID` / `VERCEL_PROJECT_ID` synced from local `.vercel/project.json` (`vercel link`)
- [x] `ci.yml` deploy job green (`vercel@55`, Node `24.x`, alias https://pt.jorgesolla.com.br)
- [x] **Git auto-deploy OFF** (`vercel.json` `git.deploymentEnabled: false` + ignore script) — correct for Actions-gated deploys
- [x] **Auto-assign Custom Production Domains ON** (Project → Settings → Environments → Production → Branch Tracking). Turning this OFF stages CLI `--prod` deploys without moving `pt.jorgesolla.com.br`; CI now re-enables + `promote`s via `scripts/vercel-ensure-production-alias.mjs`. Emergency: Actions → "Vercel promote production".
- [x] **Auto-assign Custom Production Domains ON** (Project → Settings → Environments → Production → Branch Tracking). Turning this OFF stages CLI `--prod` deploys without moving `pt.jorgesolla.com.br`; CI re-enables it.
- [x] **Domain Git Branch empty** for `pt.jorgesolla.com.br` (Settings → Domains). A branch binding only moves with Git Integration; with Git OFF, CI clears it then `promote` + `alias set`. Emergency: Actions → "Vercel promote production".
- [x] Branch `stage` deleted
- [x] GitHub Environment `stage` (and `STAGE_*` secrets) deleted
- [x] Dead stage tooling removed (`db:refresh:stage`, `agent:promote`)

View file

@ -181,6 +181,16 @@ const promoteProductionDeployment = async ({
return body
}
/**
* @typedef {{
* name: string
* gitBranch: string | null
* verified: boolean | null
* redirect: string | null
* customEnvironmentId: string | null
* }} ProjectDomainRow
*/
/**
* @param {{
* token: string
@ -188,7 +198,7 @@ const promoteProductionDeployment = async ({
* teamId: string
* fetchImpl?: typeof fetch
* }} options
* @returns {Promise<string[]>}
* @returns {Promise<ProjectDomainRow[]>}
*/
const fetchProjectDomains = async ({ token, projectId, teamId, fetchImpl = fetch }) => {
const params = new URLSearchParams({ teamId })
@ -214,8 +224,66 @@ const fetchProjectDomains = async ({ token, projectId, teamId, fetchImpl = fetch
}
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)
.filter((row) => row && typeof row.name === 'string' && row.name.trim())
.map((row) => ({
name: normalizeHostname(row.name),
gitBranch: typeof row.gitBranch === 'string' && row.gitBranch.trim() ? row.gitBranch : null,
verified: typeof row.verified === 'boolean' ? row.verified : null,
redirect: typeof row.redirect === 'string' && row.redirect.trim() ? row.redirect : null,
customEnvironmentId:
typeof row.customEnvironmentId === 'string' && row.customEnvironmentId.trim()
? row.customEnvironmentId
: null,
}))
}
/**
* Domains with a Git Branch set only auto-alias via Git Integration — CLI `--prod`
* deploys (our Actions path) will not move them until the branch binding is cleared.
* @param {{
* token: string
* projectId: string
* teamId: string
* domain: string
* fetchImpl?: typeof fetch
* }} options
*/
const clearProjectDomainGitBranch = async ({
token,
projectId,
teamId,
domain,
fetchImpl = fetch,
}) => {
const host = normalizeHostname(domain)
const params = new URLSearchParams({ teamId })
const response = await fetchImpl(
`${VERCEL_API_BASE}/v9/projects/${projectId}/domains/${encodeURIComponent(host)}?${params}`,
{
method: 'PATCH',
headers: {
authorization: `Bearer ${token}`,
'content-type': 'application/json',
},
// JSON null clears the branch binding (returns domain to Production).
body: JSON.stringify({ gitBranch: null }),
},
)
const bodyText = await response.text()
let body
try {
body = bodyText ? JSON.parse(bodyText) : {}
} catch {
throw new Error(`Vercel domain PATCH 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 domain PATCH ${response.status}: ${message}`)
}
return body
}
/**
@ -249,13 +317,19 @@ const assignDeploymentAlias = async ({ token, teamId, deploymentId, alias, fetch
} catch {
body = { raw: bodyText }
}
if (response.ok) return { ok: true, body }
if (response.ok) {
const assigned =
typeof body?.alias === 'string' ? normalizeHostname(body.alias) : normalizeHostname(alias)
return { ok: true, body, assigned, already: false }
}
const message =
typeof body?.error?.message === 'string'
? body.error.message
: bodyText || `HTTP ${response.status}`
// Only treat the exact "already on this deployment" 409 as success — other 409s
// (e.g. "domain is not allowed") must fail loudly.
if (response.status === 409 && /already assigned to (the )?given deployment/i.test(message)) {
return { ok: true, body, already: true }
return { ok: true, body, assigned: normalizeHostname(alias), already: true }
}
throw new Error(`Vercel alias API ${response.status}: ${message}`)
}
@ -476,14 +550,50 @@ export const ensureProductionCustomDomain = async ({
const projectDomains = await fetchProjectDomains({ token, projectId, teamId, fetchImpl })
note(
projectDomains.length > 0
? `project domains: ${projectDomains.join(', ')}`
? `project domains: ${projectDomains
.map((d) => {
const flags = [
d.gitBranch ? `gitBranch=${d.gitBranch}` : null,
d.redirect ? `redirect=${d.redirect}` : null,
d.customEnvironmentId ? `customEnv=${d.customEnvironmentId}` : null,
d.verified === false ? 'unverified' : null,
].filter(Boolean)
return flags.length > 0 ? `${d.name}(${flags.join(',')})` : d.name
})
.join(', ')}`
: 'project domains: (none)',
)
if (!projectDomains.includes(normalizeHostname(expectedHost))) {
const expected = normalizeHostname(expectedHost)
const domainRow = projectDomains.find((d) => d.name === expected)
if (!domainRow) {
throw new Error(
`${expectedHost} is not attached to this Vercel project. Add it under Project → Settings → Domains, then re-run. Domains seen: ${JSON.stringify(projectDomains)}`,
`${expectedHost} is not attached to this Vercel project. Add it under Project → Settings → Domains, then re-run. Domains seen: ${JSON.stringify(projectDomains.map((d) => d.name))}`,
)
}
if (domainRow.verified === false) {
throw new Error(
`${expectedHost} is on the project but not verified — finish DNS/TXT verification in Vercel Domains before aliasing.`,
)
}
if (domainRow.redirect) {
note(
`warning: ${expectedHost} redirects to ${domainRow.redirect} — alias may not serve app traffic`,
)
}
// Git-branch-bound domains only move with Git Integration deploys. We deploy via
// CLI (`vercel deploy --prebuilt --prod`), so clear the binding first.
if (domainRow.gitBranch) {
note(
`${expectedHost} had gitBranch=${domainRow.gitBranch} — clearing so CLI/Actions deploys can own Production`,
)
await clearProjectDomainGitBranch({
token,
projectId,
teamId,
domain: expected,
fetchImpl,
})
}
const deployment = await fetchDeploymentAliases({
token,
@ -517,11 +627,13 @@ export const ensureProductionCustomDomain = async ({
promoted: false,
aliased: false,
autoAssignWasFalse: autoAssign === false,
clearedGitBranch: Boolean(domainRow.gitBranch),
deployment,
steps,
}
}
let promoted = false
try {
note(`promoting ${deployment.id}`)
await promoteProductionDeployment({
@ -531,6 +643,7 @@ export const ensureProductionCustomDomain = async ({
deploymentId: deployment.id,
fetchImpl,
})
promoted = true
} catch (error) {
note(`promote skipped: ${error instanceof Error ? error.message : String(error)}`)
}
@ -540,13 +653,22 @@ export const ensureProductionCustomDomain = async ({
token,
teamId,
deploymentId: deployment.id,
alias: normalizeHostname(expectedHost),
alias: expected,
fetchImpl,
})
const assignedHost = assignResult.assigned ?? expected
if (assignedHost !== expected) {
throw new Error(
`Alias API returned unexpected host ${JSON.stringify(assignedHost)} (wanted ${expected}). body=${JSON.stringify(assignResult.body)}`,
)
}
note(
assignResult.already
? `alias API: already on this deployment`
: `alias API: assigned ${expectedHost}`,
: `alias API: assigned ${expectedHost}` +
(assignResult.body?.oldDeploymentId
? ` (was ${String(assignResult.body.oldDeploymentId)})`
: ''),
)
let verified = false
@ -573,16 +695,19 @@ export const ensureProductionCustomDomain = async ({
})
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(hosts)}; domain points to=${JSON.stringify(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.`,
)
}
note(`verified alias ${expectedHost}`)
return {
alreadyAssigned: false,
promoted: true,
promoted,
aliased: true,
autoAssignWasFalse: autoAssign === false,
clearedGitBranch: Boolean(domainRow.gitBranch),
deployment,
steps,
}

View file

@ -59,12 +59,14 @@ describe('vercelProductionAlias', () => {
calls.push(`${method} ${href}`)
if (href.includes('/v9/projects/') && href.includes('/domains') && method === 'GET') {
return jsonResponse(true, 200, { domains: [{ name: 'pt.jorgesolla.com.br' }] })
return jsonResponse(true, 200, {
domains: [{ name: 'pt.jorgesolla.com.br', verified: true, gitBranch: null }],
})
}
if (href.includes('/v9/projects/') && method === 'GET') {
return jsonResponse(true, 200, { autoAssignCustomDomains: false, name: 'jorgesolla' })
}
if (href.includes('/v9/projects/') && method === 'PATCH') {
if (href.includes('/v9/projects/') && method === 'PATCH' && !href.includes('/domains/')) {
return jsonResponse(true, 200, { autoAssignCustomDomains: true })
}
if (href.includes('/v13/deployments/')) {
@ -108,6 +110,7 @@ describe('vercelProductionAlias', () => {
return jsonResponse(true, 200, {
alias: 'pt.jorgesolla.com.br',
uid: 'alias_1',
oldDeploymentId: 'dpl_old',
created: new Date().toISOString(),
})
}
@ -125,15 +128,99 @@ describe('vercelProductionAlias', () => {
expect(result.aliased).toBe(true)
expect(result.autoAssignWasFalse).toBe(true)
expect(result.clearedGitBranch).toBe(false)
expect(calls.some((c) => c.includes('/aliases') && c.startsWith('POST '))).toBe(true)
})
it('ensureProductionCustomDomain clears gitBranch before aliasing CLI deploys', async () => {
const calls: string[] = []
let aliasHosts = ['jorgesolla-solla.vercel.app']
let domainGitBranch: string | null = 'main'
const fetchImpl = (async (url: string | URL | Request, init?: RequestInit) => {
const href = String(url)
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',
verified: true,
gitBranch: domainGitBranch,
},
],
})
}
if (
href.includes('/v9/projects/') &&
href.includes('/domains/pt.jorgesolla.com.br') &&
method === 'PATCH'
) {
domainGitBranch = null
return jsonResponse(true, 200, { name: 'pt.jorgesolla.com.br', gitBranch: null })
}
if (href.includes('/v9/projects/') && method === 'GET') {
return jsonResponse(true, 200, { autoAssignCustomDomains: true, name: 'jorgesolla' })
}
if (href.includes('/v13/deployments/')) {
return jsonResponse(true, 200, {
id: 'dpl_test',
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: '' })),
})
}
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: '' }]
: [],
})
}
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']
return jsonResponse(true, 200, {
alias: 'pt.jorgesolla.com.br',
uid: 'alias_1',
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_test',
fetchImpl,
sleepImpl: async () => {},
})
expect(result.aliased).toBe(true)
expect(result.clearedGitBranch).toBe(true)
expect(
calls.some((c) => c.startsWith('PATCH ') && c.includes('/domains/pt.jorgesolla.com.br')),
).toBe(true)
})
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' }] })
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 })