diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9ab74fe7..44496a6e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/vercel-promote.yml b/.github/workflows/vercel-promote.yml index 1faf69e5..c9f3202a 100644 --- a/.github/workflows/vercel-promote.yml +++ b/.github/workflows/vercel-promote.yml @@ -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" diff --git a/scripts/lib/vercel-production-alias.mjs b/scripts/lib/vercel-production-alias.mjs index 5840dc02..616dcb8c 100644 --- a/scripts/lib/vercel-production-alias.mjs +++ b/scripts/lib/vercel-production-alias.mjs @@ -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.`, ) diff --git a/tests/unit/vercelProductionAlias.unit.spec.ts b/tests/unit/vercelProductionAlias.unit.spec.ts index 1f37e7e0..82f006c5 100644 --- a/tests/unit/vercelProductionAlias.unit.spec.ts +++ b/tests/unit/vercelProductionAlias.unit.spec.ts @@ -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'),