fix(ci): promote Vercel deploy to pt.jorgesolla.com.br
Disabling Git auto-deploy is correct for Actions-gated production. The separate project toggle "Auto-assign Custom Production Domains" must stay ON — when OFF, `vercel deploy --prod` only aliases *.vercel.app and leaves the custom domain on an older Current (exactly what we saw after #122). After each deploy, ensure-production-alias re-enables auto-assign if needed, promotes the deployment, and verifies pt.jorgesolla.com.br is attached. Also adds workflow_dispatch "Vercel promote production" for promote-without-rebuild.
This commit is contained in:
parent
78b0b74cc2
commit
baa8f14f82
7 changed files with 626 additions and 1 deletions
9
.github/workflows/ci.yml
vendored
9
.github/workflows/ci.yml
vendored
|
|
@ -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
52
.github/workflows/vercel-promote.yml
vendored
Normal 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
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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`)
|
||||
|
|
|
|||
368
scripts/lib/vercel-production-alias.mjs
Normal file
368
scripts/lib/vercel-production-alias.mjs
Normal 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,
|
||||
}
|
||||
}
|
||||
62
scripts/vercel-ensure-production-alias.mjs
Normal file
62
scripts/vercel-ensure-production-alias.mjs
Normal 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))
|
||||
})
|
||||
129
tests/unit/vercelProductionAlias.unit.spec.ts
Normal file
129
tests/unit/vercelProductionAlias.unit.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue