diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 30aaf93b..43cfb094 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -264,6 +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). + vercel alias set "$deploy_url" pt.jorgesolla.com.br --token="$VERCEL_TOKEN" --scope="$VERCEL_ORG_ID" --yes requeue: runs-on: ubuntu-latest diff --git a/.github/workflows/vercel-promote.yml b/.github/workflows/vercel-promote.yml index 34f3f318..54ba7453 100644 --- a/.github/workflows/vercel-promote.yml +++ b/.github/workflows/vercel-promote.yml @@ -40,13 +40,20 @@ jobs: echo "::error::Missing secrets: ${missing[*]}" exit 1 fi + - name: Install Vercel CLI + run: npm install --global vercel@55 - 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" + 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 + fi diff --git a/scripts/lib/vercel-production-alias.mjs b/scripts/lib/vercel-production-alias.mjs index e9bc690f..0c3ec647 100644 --- a/scripts/lib/vercel-production-alias.mjs +++ b/scripts/lib/vercel-production-alias.mjs @@ -249,15 +249,93 @@ const assignDeploymentAlias = async ({ token, teamId, deploymentId, alias, fetch } catch { body = { raw: bodyText } } - // 409 = already assigned to this deployment — success for our purposes. - if (response.ok || response.status === 409) return body + if (response.ok) return { ok: true, body } const message = typeof body?.error?.message === 'string' ? body.error.message : bodyText || `HTTP ${response.status}` + if (response.status === 409 && /already assigned to (the )?given deployment/i.test(message)) { + return { ok: true, body, already: true } + } throw new Error(`Vercel alias API ${response.status}: ${message}`) } +/** + * Prefer the aliases list endpoint — deployment.alias on GET /v13 can omit custom hosts. + * @param {{ + * token: string + * teamId: string + * deploymentId: string + * fetchImpl?: typeof fetch + * }} options + * @returns {Promise} + */ +const listDeploymentAliasHosts = async ({ token, teamId, deploymentId, fetchImpl = fetch }) => { + const params = new URLSearchParams({ teamId }) + const response = await fetchImpl( + `${VERCEL_API_BASE}/v2/deployments/${encodeURIComponent(deploymentId)}/aliases?${params}`, + { + headers: { authorization: `Bearer ${token}` }, + }, + ) + const bodyText = await response.text() + let body + try { + body = bodyText ? JSON.parse(bodyText) : {} + } catch { + throw new Error(`Vercel list-aliases 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 list-aliases API ${response.status}: ${message}`) + } + const rows = Array.isArray(body.aliases) ? body.aliases : [] + return rows + .map((row) => (row && typeof row.alias === 'string' ? normalizeHostname(row.alias) : '')) + .filter(Boolean) +} + +/** + * @param {{ + * token: string + * teamId: string + * domain: string + * fetchImpl?: typeof fetch + * }} options + * @returns {Promise<{ alias: string, deploymentId: string | null } | null>} + */ +const fetchDomainAliasTarget = async ({ token, teamId, domain, fetchImpl = fetch }) => { + const host = normalizeHostname(domain) + const params = new URLSearchParams({ teamId, domain: host, limit: '20' }) + const response = await fetchImpl(`${VERCEL_API_BASE}/v4/aliases?${params}`, { + headers: { authorization: `Bearer ${token}` }, + }) + const bodyText = await response.text() + let body + try { + body = bodyText ? JSON.parse(bodyText) : {} + } catch { + throw new Error(`Vercel aliases-by-domain 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 aliases-by-domain API ${response.status}: ${message}`) + } + const rows = Array.isArray(body.aliases) ? body.aliases : [] + const match = rows.find((row) => normalizeHostname(row?.alias) === host) + if (!match) return null + return { + alias: normalizeHostname(match.alias), + deploymentId: typeof match.deploymentId === 'string' ? match.deploymentId : null, + } +} + /** * @param {{ * token: string @@ -360,6 +438,7 @@ export const fetchLatestProductionReadyDeployment = async ({ * expectedHost?: string * fetchImpl?: typeof fetch * sleepImpl?: (ms: number) => Promise + * onStep?: (message: string) => void * }} options */ export const ensureProductionCustomDomain = async ({ @@ -370,13 +449,19 @@ export const ensureProductionCustomDomain = async ({ expectedHost = PRODUCTION_CUSTOM_DOMAIN, fetchImpl = fetch, sleepImpl = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), + onStep, }) => { + const steps = [] + const note = (message) => { + steps.push(message) + onStep?.(message) + } + 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)') + note('autoAssignCustomDomains was false — enabling (Git OFF ≠ domain assign OFF)') await setAutoAssignCustomDomains({ token, projectId, @@ -385,18 +470,18 @@ export const ensureProductionCustomDomain = async ({ fetchImpl, }) } else { - steps.push(`autoAssignCustomDomains=${String(autoAssign)}`) + note(`autoAssignCustomDomains=${String(autoAssign)}`) } const projectDomains = await fetchProjectDomains({ token, projectId, teamId, fetchImpl }) - steps.push( + note( projectDomains.length > 0 ? `project domains: ${projectDomains.join(', ')}` : 'project domains: (none)', ) if (!projectDomains.includes(normalizeHostname(expectedHost))) { throw new Error( - `${expectedHost} is not attached to this Vercel project. Add it under Project → Settings → Domains, then re-run.`, + `${expectedHost} is not attached to this Vercel project. Add it under Project → Settings → Domains, then re-run. Domains seen: ${JSON.stringify(projectDomains)}`, ) } @@ -406,9 +491,27 @@ export const ensureProductionCustomDomain = async ({ deploymentIdOrUrl: deploymentRef, fetchImpl, }) + note(`deployment ${deployment.id} readyState=${String(deployment.readyState)}`) - if (aliasesIncludeHost(deployment.aliases, expectedHost)) { - steps.push(`already aliased to ${expectedHost}`) + const hostAlreadyOnDeployment = async () => { + const hosts = await listDeploymentAliasHosts({ + token, + teamId, + deploymentId: deployment.id, + fetchImpl, + }) + if (aliasesIncludeHost(hosts, expectedHost)) return true + const domainTarget = await fetchDomainAliasTarget({ + token, + teamId, + domain: expectedHost, + fetchImpl, + }) + return domainTarget?.deploymentId === deployment.id + } + + if (aliasesIncludeHost(deployment.aliases, expectedHost) || (await hostAlreadyOnDeployment())) { + note(`already aliased to ${expectedHost}`) return { alreadyAssigned: true, promoted: false, @@ -419,10 +522,8 @@ export const ensureProductionCustomDomain = async ({ } } - // Best-effort promote (marks Current). CLI deploys often still need an explicit alias - // for custom hostnames that only auto-apply via Git Integration. try { - steps.push(`promoting ${deployment.id}`) + note(`promoting ${deployment.id}`) await promoteProductionDeployment({ token, projectId, @@ -431,43 +532,58 @@ export const ensureProductionCustomDomain = async ({ fetchImpl, }) } catch (error) { - steps.push(`promote skipped: ${error instanceof Error ? error.message : String(error)}`) + note(`promote skipped: ${error instanceof Error ? error.message : String(error)}`) } - steps.push(`assigning alias ${expectedHost} → ${deployment.id}`) - await assignDeploymentAlias({ + note(`assigning alias ${expectedHost} → ${deployment.id}`) + const assignResult = await assignDeploymentAlias({ token, teamId, deploymentId: deployment.id, alias: normalizeHostname(expectedHost), fetchImpl, }) + note( + assignResult.already + ? `alias API: already on this deployment` + : `alias API: assigned ${expectedHost}`, + ) - 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 + let verified = false + for (let attempt = 0; attempt < 10; attempt += 1) { + if (await hostAlreadyOnDeployment()) { + verified = true + break + } await sleepImpl(1500) } - if (!aliasesIncludeHost(verified.aliases, expectedHost)) { + if (!verified) { + const hosts = await listDeploymentAliasHosts({ + token, + teamId, + deploymentId: deployment.id, + fetchImpl, + }) + const domainTarget = await fetchDomainAliasTarget({ + token, + teamId, + domain: expectedHost, + fetchImpl, + }) throw new Error( - `After alias assign, ${expectedHost} is still not on deployment ${deployment.id}. Aliases: ${JSON.stringify(verified.aliases)}`, + `After alias assign, ${expectedHost} is still not on deployment ${deployment.id}. ` + + `deployment aliases=${JSON.stringify(hosts)}; domain points to=${JSON.stringify(domainTarget)}`, ) } - steps.push(`verified alias ${expectedHost}`) + note(`verified alias ${expectedHost}`) return { alreadyAssigned: false, promoted: true, aliased: true, autoAssignWasFalse: autoAssign === false, - deployment: verified, + deployment, steps, } } diff --git a/scripts/vercel-ensure-production-alias.mjs b/scripts/vercel-ensure-production-alias.mjs index b6536aeb..fd67a1b7 100644 --- a/scripts/vercel-ensure-production-alias.mjs +++ b/scripts/vercel-ensure-production-alias.mjs @@ -45,15 +45,12 @@ const main = async () => { teamId, deploymentRef, expectedHost: PRODUCTION_CUSTOM_DOMAIN, + onStep: (message) => console.log(`[vercel-ensure-production-alias] ${message}`), }) - 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)'), + (result.aliased ? ' (aliased)' : result.alreadyAssigned ? ' (already assigned)' : ''), ) } diff --git a/tests/unit/vercelProductionAlias.unit.spec.ts b/tests/unit/vercelProductionAlias.unit.spec.ts index b9f8b7c5..355e084a 100644 --- a/tests/unit/vercelProductionAlias.unit.spec.ts +++ b/tests/unit/vercelProductionAlias.unit.spec.ts @@ -52,7 +52,7 @@ describe('vercelProductionAlias', () => { it('ensureProductionCustomDomain enables auto-assign, promotes, and aliases explicitly', async () => { const calls: string[] = [] - let aliases = ['jorgesolla-solla.vercel.app'] + let aliasHosts = ['jorgesolla-solla.vercel.app'] const fetchImpl = (async (url: string | URL | Request, init?: RequestInit) => { const href = String(url) const method = init?.method ?? 'GET' @@ -65,27 +65,48 @@ describe('vercelProductionAlias', () => { 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, + 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: new Date().toISOString(), + })), + }) + } + 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: '', + projectId: 'prj_x', + }, + ] + : [], + }) + } if (href.includes('/promote/') && method === 'POST') { return jsonResponse(true, 200, {}) } if (href.includes('/aliases') && method === 'POST') { - const body = JSON.parse(String(init?.body ?? '{}')) - expect(body.alias).toBe('pt.jorgesolla.com.br') - aliases = ['jorgesolla-solla.vercel.app', 'pt.jorgesolla.com.br'] + aliasHosts = ['jorgesolla-solla.vercel.app', 'pt.jorgesolla.com.br'] return jsonResponse(true, 200, { - alias: body.alias, + alias: 'pt.jorgesolla.com.br', uid: 'alias_1', created: new Date().toISOString(), }) @@ -104,10 +125,7 @@ describe('vercelProductionAlias', () => { expect(result.aliased).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) - expect(calls.some((c) => c.includes('/aliases'))).toBe(true) + expect(calls.some((c) => c.includes('/aliases') && c.startsWith('POST '))).toBe(true) }) it('ensureProductionCustomDomain skips work when alias already present', async () => { @@ -127,6 +145,24 @@ describe('vercelProductionAlias', () => { readyState: 'READY', }) } + if (href.includes('/v2/deployments/') && href.includes('/aliases') && method === 'GET') { + return jsonResponse(true, 200, { + aliases: [{ alias: 'pt.jorgesolla.com.br', uid: 'a1', created: '' }], + }) + } + if (href.includes('/v4/aliases') && method === 'GET') { + return jsonResponse(true, 200, { + aliases: [ + { + alias: 'pt.jorgesolla.com.br', + deploymentId: 'dpl_test', + uid: 'a1', + created: '', + projectId: 'prj_x', + }, + ], + }) + } throw new Error(`unexpected fetch ${method} ${href}`) }) as typeof fetch @@ -139,7 +175,6 @@ describe('vercelProductionAlias', () => { sleepImpl: async () => {}, }) - expect(result.promoted).toBe(false) expect(result.alreadyAssigned).toBe(true) })