From fdd318692cfea17c071c2662524e93623058b6d0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 1 Aug 2026 15:56:39 +0000 Subject: [PATCH] fix(ci): clear domain gitBranch so CLI prod deploys can alias Git-branch-bound domains only move via Git Integration. With Git auto-deploy OFF, clear the binding, then promote + alias + verify. --- .github/workflows/ci.yml | 3 +- .github/workflows/vercel-promote.yml | 24 ++- docs/AGENT-OPS.md | 5 +- docs/CUTOVER-MAIN-ONLY.md | 3 +- scripts/lib/vercel-production-alias.mjs | 149 ++++++++++++++++-- tests/unit/vercelProductionAlias.unit.spec.ts | 93 ++++++++++- 6 files changed, 252 insertions(+), 25 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 43cfb094..9ab74fe7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: diff --git a/.github/workflows/vercel-promote.yml b/.github/workflows/vercel-promote.yml index 54ba7453..1faf69e5 100644 --- a/.github/workflows/vercel-promote.yml +++ b/.github/workflows/vercel-promote.yml @@ -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 diff --git a/docs/AGENT-OPS.md b/docs/AGENT-OPS.md index d4188237..28536ebc 100644 --- a/docs/AGENT-OPS.md +++ b/docs/AGENT-OPS.md @@ -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 diff --git a/docs/CUTOVER-MAIN-ONLY.md b/docs/CUTOVER-MAIN-ONLY.md index 8e8ef8db..08a7f52a 100644 --- a/docs/CUTOVER-MAIN-ONLY.md +++ b/docs/CUTOVER-MAIN-ONLY.md @@ -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`) diff --git a/scripts/lib/vercel-production-alias.mjs b/scripts/lib/vercel-production-alias.mjs index 0c3ec647..5840dc02 100644 --- a/scripts/lib/vercel-production-alias.mjs +++ b/scripts/lib/vercel-production-alias.mjs @@ -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} + * @returns {Promise} */ 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, } diff --git a/tests/unit/vercelProductionAlias.unit.spec.ts b/tests/unit/vercelProductionAlias.unit.spec.ts index 355e084a..1f37e7e0 100644 --- a/tests/unit/vercelProductionAlias.unit.spec.ts +++ b/tests/unit/vercelProductionAlias.unit.spec.ts @@ -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 })