teqo/scripts/agent-promote.mjs
Francisco Ignácio Fontoura Solla 5b857814c3 chore: format agent scripts
2026-07-30 05:16:00 -03:00

118 lines
3.7 KiB
JavaScript

/**
* `pnpm agent:promote -- --i-am-human` — promote `stage` → `main` (production).
*
* HUMAN-ONLY. The flag is the review: branch protection on `main` requires a
* human review, and this script is it. It refuses to run without the flag and
* never runs from Cursor Automations.
*
* Checks (in order):
* 1. `main` has not diverged from `stage` (hotfix direto em main exige antes
* merge/rebase de main em stage + CI stage green de novo).
* 2. Latest ci-stage run on `stage` is green.
* 3. Creates the PR stage→main if none is open, then requires the PR checks
* (ci-pr: checks + migration-lock) green before merging.
* 4. Merges. Labels on done issues gain `in-prod` for the promoted range.
*/
import { execFileSync } from 'node:child_process'
import { dieAgent, gh, ghJson, parseArgs } from './lib/agent-github.mjs'
const die = dieAgent('promote')
const { flags } = parseArgs(process.argv.slice(2), new Set())
if (flags['i-am-human'] !== true) {
die(
'Refused: promote is human-only. Re-run as a human with:\n' +
' pnpm agent:promote -- --i-am-human\n' +
'Agents stop at merge-to-stage. Promote moves stage → main → produção (Vercel).',
)
}
const git = (args) => execFileSync('git', args, { encoding: 'utf8' }).trim()
git(['fetch', 'origin', 'main', 'stage'])
// 1. Divergence: main must be an ancestor of stage.
try {
git(['merge-base', '--is-ancestor', 'origin/main', 'origin/stage'])
} catch {
die(
'`main` divergiu de `stage` (hotfix direto em main?). Antes do promote:\n' +
' git checkout stage && git merge origin/main && git push origin stage\n' +
'…e espere o CI stage ficar verde de novo.',
)
}
// 2. CI stage green on the stage head.
const stageSha = git(['rev-parse', 'origin/stage'])
const stageRuns = ghJson([
'run',
'list',
'--branch',
'stage',
'--workflow',
'ci-stage.yml',
'--limit',
'5',
'--json',
'headSha,status,conclusion,url',
])
const latestStageRun = stageRuns.find((run) => run.headSha === stageSha)
if (
!latestStageRun ||
latestStageRun.status !== 'completed' ||
latestStageRun.conclusion !== 'success'
) {
die(
`CI stage não está verde no head de stage (${stageSha.slice(0, 8)}). ` +
`Último run: ${latestStageRun ? `${latestStageRun.status}/${latestStageRun.conclusion} — ${latestStageRun.url}` : 'nenhum'}`,
)
}
console.log('[agent:promote] CI stage green ✓')
// 3. PR stage → main.
let pr = ghJson([
'pr',
'list',
'--state',
'open',
'--base',
'main',
'--head',
'stage',
'--json',
'number,url',
])[0]
if (!pr) {
const url = gh([
'pr',
'create',
'--base',
'main',
'--head',
'stage',
'--title',
`Promote stage → main (${new Date().toISOString().slice(0, 10)})`,
'--body',
'Promote humano via `pnpm agent:promote --i-am-human`. CI stage green verificado antes da criação.',
])
pr = { number: url.split('/').pop(), url }
console.log(`[agent:promote] created PR ${pr.url}`)
} else {
console.log(`[agent:promote] reusing open PR ${pr.url}`)
}
// 4. PR checks green, then merge (the human running this script IS the review
// required by main's branch protection; --admin applies that judgment).
const checks = ghJson(['pr', 'checks', String(pr.number), '--json', 'name,state'])
const notGreen = checks.filter((check) => check.state !== 'SUCCESS')
if (notGreen.length > 0) {
die(
`PR #${pr.number} ainda tem checks não-verdes: ${notGreen
.map((check) => `${check.name}=${check.state}`)
.join(', ')}. Espere o ci-pr terminar e re-rode o promote.`,
)
}
gh(['pr', 'merge', String(pr.number), '--admin', '--merge'])
console.log(`[agent:promote] merged stage → main via PR #${pr.number}. Deploy Vercel a caminho.`)