teqo-backup/scripts/agent-promote.mjs
Francisco Ignácio Fontoura Solla b533139eb2 feat(ops): promote último commit ci-stage green sem exigir head verde
agent:promote passa a escolher o SHA mais novo à frente de main com
ci-stage success e usa promote/last-green quando o head de stage está
vermelho, destravando o bootstrap manual para main.
2026-07-30 21:56:50 -03:00

175 lines
5 KiB
JavaScript

/**
* `pnpm agent:promote -- --i-am-human` — manual override: promote `stage` → `main`.
*
* Normal path: `promote-stage-to-main.yml` auto-merges when ci-stage is green on
* the current stage head. Use this when the workflow failed or you need to promote
* out of band — including when stage head is red but an older commit on stage
* already passed ci-stage.
*
* Checks (in order):
* 1. `main` has not diverged from `stage` (hotfix direto em main exige merge
* de main em stage antes).
* 2. Newest commit on `origin/main..origin/stage` with a successful ci-stage run.
* 3. PR into `main` from `stage` (head green) or `promote/last-green` (older green).
* 4. Merge (ci-stage on the promoted SHA is the pre-promote gate; ci.yml on main
* is the post-merge safety net).
*/
import { execFileSync } from 'node:child_process'
import { dieAgent, gh, ghJson, parseArgs } from './lib/agent-github.mjs'
import {
findLastGreenPromoteSha,
greenCiStageHeadShas,
PROMOTE_GREEN_BRANCH,
} from './lib/agent-promote-target.mjs'
const die = dieAgent('promote')
const { flags } = parseArgs(process.argv.slice(2), new Set())
if (flags['i-am-human'] !== true) {
die(
'Refused: promote override is human-only. Re-run as a human with:\n' +
' pnpm agent:promote -- --i-am-human\n' +
'Auto-promote runs via promote-stage-to-main.yml when ci-stage is green on stage head.',
)
}
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.',
)
}
const stageSha = git(['rev-parse', 'origin/stage'])
const mainSha = git(['rev-parse', 'origin/main'])
if (stageSha === mainSha) {
die(`main já está no head de stage (${stageSha.slice(0, 8)}) — nada a promover.`)
}
const commitsAhead = git(['log', `origin/main..origin/stage`, '--format=%H'])
.split('\n')
.filter(Boolean)
const stageRuns = ghJson([
'run',
'list',
'--branch',
'stage',
'--workflow',
'ci-stage.yml',
'--limit',
'100',
'--json',
'headSha,status,conclusion,url',
])
const promoteSha = findLastGreenPromoteSha(commitsAhead, greenCiStageHeadShas(stageRuns))
if (!promoteSha) {
die(
`Nenhum commit em stage à frente de main tem ci-stage verde (stage head ${stageSha.slice(0, 8)}). ` +
'Corrija stage ou espere um run verde antes do promote.',
)
}
const promoteRun = stageRuns.find(
(run) => run.headSha === promoteSha && run.status === 'completed' && run.conclusion === 'success',
)
if (promoteSha === stageSha) {
console.log(`[agent:promote] CI stage green no head de stage (${stageSha.slice(0, 8)}) ✓`)
} else {
console.log(
`[agent:promote] stage head ${stageSha.slice(0, 8)} não está verde; ` +
`promovendo último commit ci-stage green ${promoteSha.slice(0, 8)} ✓`,
)
if (promoteRun?.url) {
console.log(`[agent:promote] run: ${promoteRun.url}`)
}
}
const prHead = promoteSha === stageSha ? 'stage' : PROMOTE_GREEN_BRANCH
if (prHead !== 'stage') {
execFileSync(
'git',
['push', 'origin', `${promoteSha}:refs/heads/${PROMOTE_GREEN_BRANCH}`, '--force'],
{
stdio: 'inherit',
},
)
}
// 3. PR → main (create if needed).
let pr = ghJson([
'pr',
'list',
'--state',
'open',
'--base',
'main',
'--head',
prHead,
'--json',
'number,url',
])[0]
if (!pr && prHead !== 'stage') {
const staleStagePr = ghJson([
'pr',
'list',
'--state',
'open',
'--base',
'main',
'--head',
'stage',
'--json',
'number,url',
])[0]
if (staleStagePr) {
console.log(
`[agent:promote] fechando PR #${staleStagePr.number} stage→main (head vermelho; promote em ${promoteSha.slice(0, 8)})`,
)
gh(['pr', 'close', String(staleStagePr.number)])
}
}
if (!pr) {
const body =
promoteSha === stageSha
? 'Override manual via `pnpm agent:promote --i-am-human`. CI stage green no head de stage.'
: `Override manual via \`pnpm agent:promote --i-am-human\`. Promove ci-stage green \`${promoteSha.slice(0, 8)}\` (stage head \`${stageSha.slice(0, 8)}\` não está verde).`
const url = gh([
'pr',
'create',
'--base',
'main',
'--head',
prHead,
'--title',
`Promote stage → main (${new Date().toISOString().slice(0, 10)})`,
'--body',
body,
])
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. Merge (ci-stage on promoteSha was the pre-promote gate; ci.yml on main is the safety net).
gh(['pr', 'merge', String(pr.number), '--admin', '--merge'])
console.log(
`[agent:promote] merged ${prHead} (${promoteSha.slice(0, 8)}) → main via PR #${pr.number}. Deploy Vercel a caminho.`,
)