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.
175 lines
5 KiB
JavaScript
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.`,
|
|
)
|