Merge pull request #123 from fsolla/cursor/fix-vercel-prod-domain-promote-1749

fix(ci) — promote Vercel deploy to pt.jorgesolla.com.br
This commit is contained in:
Francisco Solla 2026-08-01 12:34:53 -03:00 committed by GitHub
commit e1671d3e5d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 626 additions and 1 deletions

View file

@ -256,7 +256,14 @@ jobs:
# "Could not retrieve Project Settings" usually means bad/expired token or wrong IDs (403).
vercel pull --yes --environment=production --token="$VERCEL_TOKEN"
vercel build --prod --token="$VERCEL_TOKEN"
vercel deploy --prebuilt --prod --archive=tgz --token="$VERCEL_TOKEN"
# stdout is always the deployment URL (CLI contract). Capture it — do not
# pipe the whole CLI log or the exit code / URL get lost.
deploy_url=$(vercel deploy --prebuilt --prod --archive=tgz --token="$VERCEL_TOKEN")
echo "Deployed: $deploy_url"
# Git auto-deploy OFF is intentional. If "Auto-assign Custom Production
# 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"
requeue:
runs-on: ubuntu-latest

52
.github/workflows/vercel-promote.yml vendored Normal file
View file

@ -0,0 +1,52 @@
# Promote a READY production deployment to custom domains (no rebuild).
# Use when `vercel deploy --prod` staged a deployment but left pt.jorgesolla.com.br
# on an older Current (Auto-assign Custom Production Domains OFF).
name: Vercel promote production
on:
workflow_dispatch:
inputs:
deployment:
description: 'Deployment URL or id (empty = latest READY production)'
required: false
type: string
default: ''
permissions:
contents: read
jobs:
promote:
runs-on: ubuntu-latest
timeout-minutes: 10
env:
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: 24
package-manager-cache: false
- name: Require Vercel credentials
run: |
set -euo pipefail
missing=()
[ -z "${VERCEL_TOKEN:-}" ] && missing+=(VERCEL_TOKEN)
[ -z "${VERCEL_ORG_ID:-}" ] && missing+=(VERCEL_ORG_ID)
[ -z "${VERCEL_PROJECT_ID:-}" ] && missing+=(VERCEL_PROJECT_ID)
if [ "${#missing[@]}" -gt 0 ]; then
echo "::error::Missing secrets: ${missing[*]}"
exit 1
fi
- 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"
else
node scripts/vercel-ensure-production-alias.mjs --latest
fi

View file

@ -79,6 +79,11 @@ Fast gate: `pnpm gate:fast`. Push: `pnpm push`.
Vercel Git builds: `scripts/vercel-ignore-build.sh` skipeia **todas** as branches (incl. `main`) — prod só via Action.
**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`.
## Cursor Cloud
`.cursor/environment.json` instala deps + Postgres nativo + `db:seed:minimal`. Sem secrets de prod. Preferir `pnpm push`; escape `git push --no-verify` só no cutover documentado acima.

View file

@ -8,6 +8,8 @@ Completed 2026-08-01. Branch `stage` and GitHub Environment `stage` are deleted.
- [x] Repo secrets present (`VERCEL_*`, `POOL_GITHUB_TOKEN`)
- [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] Branch `stage` deleted
- [x] GitHub Environment `stage` (and `STAGE_*` secrets) deleted
- [x] Dead stage tooling removed (`db:refresh:stage`, `agent:promote`)

View file

@ -0,0 +1,368 @@
/**
* Ensure a Vercel production deployment is assigned to custom domains.
*
* Disabling Git auto-deploy (`git.deploymentEnabled: false`) is correct for
* Actions-gated deploys. Separately, project setting
* `autoAssignCustomDomains` must stay ON — when OFF, `vercel deploy --prod`
* creates a staged production deployment that only gets `*.vercel.app` and
* leaves custom domains (e.g. pt.jorgesolla.com.br) on an older Current.
*/
const VERCEL_API_BASE = 'https://api.vercel.com'
/** Canonical Teqo production hostname — WordPress stays on jorgesolla.com.br. */
export const PRODUCTION_CUSTOM_DOMAIN = 'pt.jorgesolla.com.br'
/**
* @param {string} value
* @returns {string}
*/
export const normalizeHostname = (value) =>
String(value ?? '')
.trim()
.replace(/^https?:\/\//i, '')
.replace(/\/.*$/, '')
.toLowerCase()
/**
* @param {unknown} aliases
* @param {string} expectedHost
*/
export const aliasesIncludeHost = (aliases, expectedHost) => {
const want = normalizeHostname(expectedHost)
if (!want) return false
if (!Array.isArray(aliases)) return false
return aliases.some((alias) => normalizeHostname(alias) === want)
}
/**
* Normalize a CLI stdout URL / inspect path / bare id into something the
* deployments GET endpoint accepts (`dpl_…`, short id, or hostname).
* @param {string} deploymentRef
* @returns {{ kind: 'id' | 'url'; value: string }}
*/
export const parseDeploymentRef = (deploymentRef) => {
const raw = String(deploymentRef ?? '').trim()
if (!raw) throw new Error('deployment ref is required')
if (/^dpl_[A-Za-z0-9]+$/.test(raw)) return { kind: 'id', value: raw }
try {
const url = new URL(raw.includes('://') ? raw : `https://${raw}`)
// Dashboard inspect: vercel.com/{team}/{project}/{deploymentId}
if (url.hostname === 'vercel.com') {
const pathId = url.pathname.split('/').filter(Boolean).at(-1)
if (pathId && /^[A-Za-z0-9]+$/.test(pathId)) return { kind: 'id', value: pathId }
}
if (url.hostname.endsWith('.vercel.app') || url.hostname.includes('.')) {
return { kind: 'url', value: url.hostname }
}
} catch {
// fall through
}
if (/^[A-Za-z0-9_]+$/.test(raw)) return { kind: 'id', value: raw }
return { kind: 'url', value: normalizeHostname(raw) }
}
/**
* @param {{
* token: string
* projectId: string
* teamId: string
* fetchImpl?: typeof fetch
* }} options
*/
const fetchVercelProject = async ({ token, projectId, teamId, fetchImpl = fetch }) => {
const params = new URLSearchParams({ teamId })
const response = await fetchImpl(`${VERCEL_API_BASE}/v9/projects/${projectId}?${params}`, {
headers: { authorization: `Bearer ${token}` },
})
const bodyText = await response.text()
let body
try {
body = bodyText ? JSON.parse(bodyText) : {}
} catch {
throw new Error(`Vercel project 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 API ${response.status}: ${message}`)
}
return body
}
/**
* @param {{
* token: string
* projectId: string
* teamId: string
* enabled: boolean
* fetchImpl?: typeof fetch
* }} options
*/
const setAutoAssignCustomDomains = async ({
token,
projectId,
teamId,
enabled,
fetchImpl = fetch,
}) => {
const params = new URLSearchParams({ teamId })
const response = await fetchImpl(`${VERCEL_API_BASE}/v9/projects/${projectId}?${params}`, {
method: 'PATCH',
headers: {
authorization: `Bearer ${token}`,
'content-type': 'application/json',
},
body: JSON.stringify({ autoAssignCustomDomains: enabled }),
})
const bodyText = await response.text()
let body
try {
body = bodyText ? JSON.parse(bodyText) : {}
} catch {
throw new Error(`Vercel project 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 project PATCH ${response.status}: ${message}`)
}
return body
}
/**
* @param {{
* token: string
* projectId: string
* teamId: string
* deploymentId: string
* fetchImpl?: typeof fetch
* }} options
*/
const promoteProductionDeployment = async ({
token,
projectId,
teamId,
deploymentId,
fetchImpl = fetch,
}) => {
const params = new URLSearchParams({ teamId })
const response = await fetchImpl(
`${VERCEL_API_BASE}/v10/projects/${projectId}/promote/${deploymentId}?${params}`,
{
method: 'POST',
headers: {
authorization: `Bearer ${token}`,
'content-type': 'application/json',
},
body: '{}',
},
)
const bodyText = await response.text()
let body
try {
body = bodyText ? JSON.parse(bodyText) : {}
} catch {
body = { raw: bodyText }
}
if (!response.ok) {
const message =
typeof body?.error?.message === 'string'
? body.error.message
: bodyText || `HTTP ${response.status}`
throw new Error(`Vercel promote API ${response.status}: ${message}`)
}
return body
}
/**
* @param {{
* token: string
* teamId: string
* deploymentIdOrUrl: string
* fetchImpl?: typeof fetch
* }} options
*/
const fetchDeploymentAliases = async ({ token, teamId, deploymentIdOrUrl, fetchImpl = fetch }) => {
const parsed = parseDeploymentRef(deploymentIdOrUrl)
const params = new URLSearchParams({ teamId })
// GET /v13/deployments/{idOrUrl} accepts dpl_ id, short id, or hostname.
const pathValue = encodeURIComponent(parsed.value)
const response = await fetchImpl(`${VERCEL_API_BASE}/v13/deployments/${pathValue}?${params}`, {
headers: { authorization: `Bearer ${token}` },
})
const bodyText = await response.text()
let body
try {
body = bodyText ? JSON.parse(bodyText) : {}
} catch {
throw new Error(`Vercel deployment 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 deployment API ${response.status}: ${message}`)
}
const aliases = Array.isArray(body.alias)
? body.alias
: Array.isArray(body.aliases)
? body.aliases
: []
return {
id: typeof body.id === 'string' ? body.id : parsed.value,
url: typeof body.url === 'string' ? body.url : null,
aliases,
readyState: body.readyState ?? body.status ?? null,
}
}
/**
* @param {{
* token: string
* projectId: string
* teamId: string
* fetchImpl?: typeof fetch
* }} options
* @returns {Promise<{ id: string, url: string | null, ready: number | null } | null>}
*/
export const fetchLatestProductionReadyDeployment = async ({
token,
projectId,
teamId,
fetchImpl = fetch,
}) => {
const params = new URLSearchParams({
projectId,
teamId,
target: 'production',
state: 'READY',
limit: '1',
})
const response = await fetchImpl(`${VERCEL_API_BASE}/v6/deployments?${params}`, {
headers: { authorization: `Bearer ${token}` },
})
const bodyText = await response.text()
let body
try {
body = bodyText ? JSON.parse(bodyText) : {}
} catch {
throw new Error(`Vercel deployments list 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 deployments list ${response.status}: ${message}`)
}
const deployment = Array.isArray(body.deployments) ? body.deployments[0] : null
if (!deployment || typeof deployment !== 'object') return null
const id = typeof deployment.uid === 'string' ? deployment.uid : deployment.id
if (typeof id !== 'string') return null
return {
id,
url: typeof deployment.url === 'string' ? deployment.url : null,
ready: typeof deployment.ready === 'number' ? deployment.ready : null,
}
}
/**
* @param {{
* token: string
* projectId: string
* teamId: string
* deploymentRef: string
* expectedHost?: string
* fetchImpl?: typeof fetch
* sleepImpl?: (ms: number) => Promise<void>
* }} options
*/
export const ensureProductionCustomDomain = async ({
token,
projectId,
teamId,
deploymentRef,
expectedHost = PRODUCTION_CUSTOM_DOMAIN,
fetchImpl = fetch,
sleepImpl = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
}) => {
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)')
await setAutoAssignCustomDomains({
token,
projectId,
teamId,
enabled: true,
fetchImpl,
})
} else {
steps.push(`autoAssignCustomDomains=${String(autoAssign)}`)
}
const deployment = await fetchDeploymentAliases({
token,
teamId,
deploymentIdOrUrl: deploymentRef,
fetchImpl,
})
if (aliasesIncludeHost(deployment.aliases, expectedHost)) {
steps.push(`already aliased to ${expectedHost}`)
return {
alreadyAssigned: true,
promoted: false,
autoAssignWasFalse: autoAssign === false,
deployment,
steps,
}
}
steps.push(`promoting ${deployment.id} so ${expectedHost} points at this deployment`)
await promoteProductionDeployment({
token,
projectId,
teamId,
deploymentId: deployment.id,
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({
token,
teamId,
deploymentIdOrUrl: deployment.id,
fetchImpl,
})
if (aliasesIncludeHost(verified.aliases, expectedHost)) break
await sleepImpl(1500)
}
if (!aliasesIncludeHost(verified.aliases, expectedHost)) {
throw new Error(
`After promote, ${expectedHost} is still not on deployment ${deployment.id}. Aliases: ${JSON.stringify(verified.aliases)}`,
)
}
steps.push(`verified alias ${expectedHost}`)
return {
alreadyAssigned: false,
promoted: true,
autoAssignWasFalse: autoAssign === false,
deployment: verified,
steps,
}
}

View file

@ -0,0 +1,62 @@
#!/usr/bin/env node
/**
* vercel-ensure-production-alias — after `vercel deploy --prod`, make sure the
* custom production hostname (pt.jorgesolla.com.br) points at the deployment.
*
* Usage:
* node scripts/vercel-ensure-production-alias.mjs <deployment-url-or-id>
* node scripts/vercel-ensure-production-alias.mjs --latest
*
* Env: VERCEL_TOKEN, VERCEL_PROJECT_ID, VERCEL_ORG_ID
*/
import { dieWithLabel } from './lib/cli.mjs'
import {
ensureProductionCustomDomain,
fetchLatestProductionReadyDeployment,
PRODUCTION_CUSTOM_DOMAIN,
} from './lib/vercel-production-alias.mjs'
const die = dieWithLabel('vercel-ensure-production-alias')
const main = async () => {
const token = process.env.VERCEL_TOKEN
const projectId = process.env.VERCEL_PROJECT_ID
const teamId = process.env.VERCEL_ORG_ID
if (!token) die('VERCEL_TOKEN is required')
if (!projectId) die('VERCEL_PROJECT_ID is required')
if (!teamId) die('VERCEL_ORG_ID is required')
const arg = process.argv[2]
if (!arg) {
die('pass a deployment URL/id, or --latest')
}
let deploymentRef = arg
if (arg === '--latest') {
const latest = await fetchLatestProductionReadyDeployment({ token, projectId, teamId })
if (!latest) die('no READY production deployment found')
deploymentRef = latest.url ? `https://${latest.url}` : latest.id
console.log(`[vercel-ensure-production-alias] latest READY production: ${deploymentRef}`)
}
const result = await ensureProductionCustomDomain({
token,
projectId,
teamId,
deploymentRef,
expectedHost: PRODUCTION_CUSTOM_DOMAIN,
})
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)'),
)
}
main().catch((error) => {
die(error instanceof Error ? error.message : String(error))
})

View file

@ -0,0 +1,129 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest'
import {
aliasesIncludeHost,
ensureProductionCustomDomain,
normalizeHostname,
parseDeploymentRef,
PRODUCTION_CUSTOM_DOMAIN,
} from '../../scripts/lib/vercel-production-alias.mjs'
const jsonResponse = (ok: boolean, status: number, payload: unknown) =>
({
ok,
status,
text: async () => (payload === null ? '' : JSON.stringify(payload)),
}) as Response
describe('vercelProductionAlias', () => {
it('pins the Teqo production hostname', () => {
expect(PRODUCTION_CUSTOM_DOMAIN).toBe('pt.jorgesolla.com.br')
})
it('normalizeHostname strips protocol and path', () => {
expect(normalizeHostname('https://PT.jorgesolla.com.br/campanha')).toBe('pt.jorgesolla.com.br')
})
it('aliasesIncludeHost matches hostnames case-insensitively', () => {
expect(
aliasesIncludeHost(
['jorgesolla-solla.vercel.app', 'pt.jorgesolla.com.br'],
'PT.jorgesolla.com.br',
),
).toBe(true)
expect(aliasesIncludeHost(['jorgesolla-solla.vercel.app'], 'pt.jorgesolla.com.br')).toBe(false)
})
it('parseDeploymentRef accepts inspect paths and vercel.app hosts', () => {
expect(
parseDeploymentRef('https://vercel.com/solla/jorgesolla/CQkmCejZSvRoFtYP8hHbjzP3pc1L'),
).toEqual({
kind: 'id',
value: 'CQkmCejZSvRoFtYP8hHbjzP3pc1L',
})
expect(parseDeploymentRef('https://jorgesolla-k0lwit3lp-solla.vercel.app')).toEqual({
kind: 'url',
value: 'jorgesolla-k0lwit3lp-solla.vercel.app',
})
expect(parseDeploymentRef('dpl_abc123')).toEqual({ kind: 'id', value: 'dpl_abc123' })
})
it('ensureProductionCustomDomain enables auto-assign and promotes when domain missing', async () => {
const calls: string[] = []
let aliases = ['jorgesolla-solla.vercel.app']
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/') && method === 'GET') {
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,
readyState: 'READY',
})
}
if (href.includes('/promote/') && method === 'POST') {
aliases = ['jorgesolla-solla.vercel.app', 'pt.jorgesolla.com.br']
return jsonResponse(true, 200, {})
}
throw new Error(`unexpected fetch ${method} ${href}`)
}) as typeof fetch
const result = await ensureProductionCustomDomain({
token: 'tok',
projectId: 'prj_x',
teamId: 'team_x',
deploymentRef: 'https://jorgesolla-test.vercel.app',
fetchImpl,
sleepImpl: async () => {},
})
expect(result.promoted).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)
})
it('ensureProductionCustomDomain skips promote 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/') && method === 'GET') {
return jsonResponse(true, 200, { autoAssignCustomDomains: true })
}
if (href.includes('/v13/deployments/')) {
return jsonResponse(true, 200, {
id: 'dpl_test',
alias: ['pt.jorgesolla.com.br'],
readyState: 'READY',
})
}
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.promoted).toBe(false)
expect(result.alreadyAssigned).toBe(true)
})
})