From 3fc4c7e603decd3c5e915edaaf0934c2a1229977 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Mon, 5 Oct 2026 05:00:24 +0000 Subject: [PATCH] =?UTF-8?q?ci(merge-queue):=20=ED=81=90=20=ED=88=AC?= =?UTF-8?q?=EC=9E=85=EC=9D=84=20=EC=82=AC=EB=9E=8C=20=ED=86=A0=ED=81=B0?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=ED=95=9C=EB=8B=A4=20(#2254)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GITHUB_TOKEN 으로 넣은 큐 항목은 merge_group CI 를 띄우지 못해 큐 맨 앞을 60분 붙잡고 CI_TIMEOUT 으로 빠진다. 자동 머지 예약은 MERGE_QUEUE_TOKEN 으로 하고, 방출 처리의 재투입은 시크릿을 쓰는 workflow_dispatch 워크플로로 넘긴다. 시크릿이 없으면 GITHUB_TOKEN 으로 넣지 않는다. --- .../find-viewed-auto-merge-targets.test.mjs | 7 +- .../scripts/handle-merge-queue-dequeue.mjs | 122 +++++++++++++++--- .../handle-merge-queue-dequeue.test.mjs | 107 +++++++++++++++ .github/workflows/merge-queue-dequeue.yml | 9 +- .github/workflows/merge-queue-requeue.yml | 53 ++++++++ .github/workflows/viewed-auto-merge.yml | 15 ++- 6 files changed, 287 insertions(+), 26 deletions(-) create mode 100644 .github/workflows/merge-queue-requeue.yml diff --git a/.github/scripts/find-viewed-auto-merge-targets.test.mjs b/.github/scripts/find-viewed-auto-merge-targets.test.mjs index 1ca002167e..0db9f9a3ce 100644 --- a/.github/scripts/find-viewed-auto-merge-targets.test.mjs +++ b/.github/scripts/find-viewed-auto-merge-targets.test.mjs @@ -49,13 +49,16 @@ test("Draft·이미 예약·큐에 있는 PR 은 건드리지 않는다", () => assert.equal(decideViewedAutoMerge(pullRequest({ isInMergeQueue: true }), files).enable, false); }); -test("워크플로는 본인 토큰으로 읽고 GITHUB_TOKEN 으로 승인한 HEAD 만 예약한다", async () => { +test("워크플로는 본인 토큰으로 읽고 사람 토큰으로 승인한 HEAD 만 예약한다", async () => { const workflow = await readFile(new URL("../workflows/viewed-auto-merge.yml", import.meta.url), "utf8"); assert.match(workflow, /^permissions: \{\}$/m); assert.match(workflow, /VIEWED_TOKEN: \$\{\{ secrets\.VIEWED_TOKEN \}\}/); assert.match(workflow, /ref: \$\{\{ github\.event\.repository\.default_branch \}\}/); assert.match(workflow, /persist-credentials: false/); - assert.match(workflow, /GH_TOKEN: \$\{\{ github\.token \}\}/); + // GITHUB_TOKEN 으로 예약·투입하면 merge_group CI 가 뜨지 않아 큐가 60분 막힌다(1005 #2208). + assert.match(workflow, /GH_TOKEN: \$\{\{ secrets\.MERGE_QUEUE_TOKEN \}\}/); + assert.doesNotMatch(workflow, /GH_TOKEN: \$\{\{ github\.token \}\}/); + assert.match(workflow, /if \[ -z "\$GH_TOKEN" \]; then[\s\S]*?exit 0/); assert.match(workflow, /--match-head-commit "\$head_sha"/); // 우회 머지 금지. merge queue 와 required check 를 그대로 탄다. assert.doesNotMatch(workflow, /--admin/); diff --git a/.github/scripts/handle-merge-queue-dequeue.mjs b/.github/scripts/handle-merge-queue-dequeue.mjs index 706d1c04e9..c05e6be0ad 100644 --- a/.github/scripts/handle-merge-queue-dequeue.mjs +++ b/.github/scripts/handle-merge-queue-dequeue.mjs @@ -25,6 +25,12 @@ // merge-async 는 202 pending 으로 받으므로 결과(enqueued·merged·failed)를 폴링해 확인하고, failed·미확정은 job 을 // red 로 남긴다. 스택 위쪽 PR 은 자동 재투입하지 않는다 — merge-async 가 downstack 의 열린 PR 까지 함께 넣어 // 아래 PR 의 판정(실패 job 이면 재투입 안 함)을 덮기 때문이다. 위쪽 PR 에는 마커 없는 안내 코멘트만 남긴다. +// +// 재투입은 이 job 이 직접 하지 않고 merge-queue-requeue.yml 에 넘긴다(#2254). GITHUB_TOKEN 으로 큐에 넣으면 GitHub 이 +// 그 이벤트로 워크플로를 띄우지 않아 merge_group CI 가 하나도 돌지 않고, 큐 맨 앞을 60분(check timeout) 붙잡은 뒤 +// CI_TIMEOUT 으로 다시 빠지면서 뒤의 PR 들까지 다시 검증하게 만든다(1005 #2208 실측: 봇이 넣은 그룹만 체크 0개). +// 투입에는 사람 토큰(MERGE_QUEUE_TOKEN)이 필요한데 이 워크플로는 pull_request_target 브리지라 시크릿을 쓰지 않는다. +// workflow_dispatch 는 GITHUB_TOKEN 으로 보내도 실행되는 예외라서, 판정과 마커는 여기서 끝내고 투입만 dispatch 로 넘긴다. import { readFile } from "node:fs/promises"; import path from "node:path"; @@ -329,6 +335,67 @@ export async function enqueueStacked(api, repository, number, headSha, { sleep = } } +export const REQUEUE_WORKFLOW_FILE = "merge-queue-requeue.yml"; + +/** 재투입 워크플로를 띄운다. 입력의 head 로 고정해 그사이 새 커밋이 올라오면 그쪽이 투입하지 않는다. */ +export async function dispatchRequeue(api, repository, { number, headSha, ref }) { + await api(`/repos/${repository}/actions/workflows/${REQUEUE_WORKFLOW_FILE}/dispatches`, { + method: "POST", + body: { ref, inputs: { pull_request_number: String(number), head_sha: headSha } }, + }); +} + +/** 큐 투입 자체. 스택 PR 은 merge-async, 나머지는 GraphQL 이다(#2177). [api] 의 토큰 주인이 투입 주체가 된다. */ +export async function performRequeue({ api, repository, number, live, logger = console, sleep = defaultSleep }) { + const headSha = live.headRefOid; + if (isStackMember(live)) { + const enqueueResult = await enqueueStacked(api, repository, number, headSha, { sleep }); + if (enqueueResult.unresolved) { + const refreshed = await fetchLivePullRequest(api, repository, number); + if (!refreshed?.mergeQueueEntry) { + throw new Error(`merge-async 결과 미확정: ${JSON.stringify(enqueueResult)} 큐에도 없다`); + } + logger.log(`#${number} 스택 ${live.stack?.number ?? "?"} 재투입(merge-async) → live 재조회로 큐 진입 확인(${refreshed.mergeQueueEntry.state})`); + } else { + logger.log(`#${number} 스택 ${live.stack?.number ?? "?"} 재투입(merge-async) → ${enqueueResult?.status ?? "?"} ${enqueueResult?.details?.message ?? ""}`.trimEnd()); + } + return "merge-async"; + } + const entry = await enqueue(api, live.id); + logger.log(`#${number} 재투입 → ${entry?.state ?? "?"} position=${entry?.position ?? "?"}`); + return "graphql"; +} + +/** + * merge-queue-requeue.yml 쪽. dispatch 를 받은 뒤 live 상태를 다시 보고, 판정한 head 그대로 열려 있고 큐 밖일 때만 넣는다. + * 판정·마커는 방출 처리 job 이 이미 끝냈으므로 여기서는 코멘트를 쓰지 않는다. + */ +export async function requeueDispatched({ api, repository, number, headSha, logger = console, sleep = defaultSleep }) { + const live = await fetchLivePullRequest(api, repository, number); + if (!live) throw new Error(`#${number} 를 찾을 수 없습니다`); + let skip = null; + if (live.state !== "OPEN") skip = live.state; + else if (live.mergeQueueEntry) skip = "already-queued"; + else if (live.headRefOid !== headSha) skip = "head-changed"; + else if (isStackMember(live) && !isStackBottom(live)) skip = "stack-upper"; + if (skip) { + logger.log(`#${number} 재투입 생략(${skip}) — 판정 head ${shortSha(headSha)}, 지금 head ${shortSha(live.headRefOid ?? "")}`); + return { action: "none", why: skip }; + } + const enqueueMethod = await performRequeue({ api, repository, number, live, logger, sleep }); + return { action: "requeue", enqueueMethod }; +} + +export function renderMissingQueueTokenComment({ headSha, repository, number, stack = false }) { + return [ + "### merge queue 방출: 재투입하지 못했다", + "", + `head \`${shortSha(headSha)}\`. \`MERGE_QUEUE_TOKEN\` 시크릿이 없다.`, + "GITHUB_TOKEN 으로 넣으면 merge_group CI 가 돌지 않아 60분 뒤 CI_TIMEOUT 으로 다시 빠지므로 넣지 않았다.", + `\`${manualEnqueueCommand({ repository, number, stack })}\` 로 직접 투입한다.`, + ].join("\n"); +} + export async function handleDequeue({ api, repository, @@ -337,6 +404,7 @@ export async function handleDequeue({ dryRun = false, logger = console, sleep = defaultSleep, + requeue = null, }) { const live = await fetchLivePullRequest(api, repository, number); if (!live) throw new Error(`#${number} 를 찾을 수 없습니다`); @@ -372,33 +440,46 @@ export async function handleDequeue({ } else if (action === "requeue") { // 마커를 먼저 남긴다 — 재투입 뒤 코멘트가 실패하면 다음 방출에서 두 번째 재투입이 나간다. await postComment(api, repository, number, renderRequeueComment({ reason, headSha, failedJobs })); - if (stack) { - const enqueueResult = await enqueueStacked(api, repository, number, headSha, { sleep }); - if (enqueueResult.unresolved) { - const refreshed = await fetchLivePullRequest(api, repository, number); - if (!refreshed?.mergeQueueEntry) { - throw new Error(`merge-async 결과 미확정: ${JSON.stringify(enqueueResult)} 큐에도 없다`); - } - logger.log(`#${number} 스택 ${live.stack?.number ?? "?"} 재투입(merge-async) → live 재조회로 큐 진입 확인(${refreshed.mergeQueueEntry.state})`); - } else { - logger.log(`#${number} 스택 ${live.stack?.number ?? "?"} 재투입(merge-async) → ${enqueueResult?.status ?? "?"} ${enqueueResult?.details?.message ?? ""}`.trimEnd()); - } - result.enqueueMethod = "merge-async"; - } else { - const entry = await enqueue(api, live.id); - logger.log(`#${number} 재투입 → ${entry?.state ?? "?"} position=${entry?.position ?? "?"}`); - result.enqueueMethod = "graphql"; - } + result.enqueueMethod = requeue + ? await requeue({ number, headSha, live }) + : await performRequeue({ api, repository, number, live, logger, sleep }); } return result; } +function requiredEnv(name) { + const value = process.env[name]; + if (!value) throw new Error(`${name} 가 필요합니다.`); + return value; +} + +async function requeueMain(repository) { + const commentApi = createApi(requiredEnv("GITHUB_TOKEN")); + const number = Number(process.env.PULL_REQUEST_NUMBER); + const headSha = requiredEnv("HEAD_SHA"); + if (!Number.isInteger(number) || number <= 0) { + throw new Error("PULL_REQUEST_NUMBER 가 양의 정수여야 합니다."); + } + const queueToken = process.env.MERGE_QUEUE_TOKEN; + if (!queueToken) { + const live = await fetchLivePullRequest(commentApi, repository, number); + await postComment(commentApi, repository, number, renderMissingQueueTokenComment({ headSha, repository, number, stack: isStackMember(live) })); + throw new Error("MERGE_QUEUE_TOKEN 시크릿이 없어 재투입하지 못했다."); + } + const result = await requeueDispatched({ api: createApi(queueToken), repository, number, headSha }); + console.log(`merge-queue-requeue: #${number} head=${shortSha(headSha)} → ${result.action}${result.why ? ` (${result.why})` : ""}${result.enqueueMethod ? ` (${result.enqueueMethod})` : ""}`); +} + async function main() { const token = process.env.GITHUB_TOKEN; const repository = process.env.GITHUB_REPOSITORY; if (!token || !repository) { throw new Error("GITHUB_TOKEN·GITHUB_REPOSITORY 가 필요합니다."); } + if (process.env.MODE === "requeue") { + await requeueMain(repository); + return; + } let number = Number(process.env.PULL_REQUEST_NUMBER); let reason = process.env.DEQUEUE_REASON; if ((!Number.isInteger(number) || number <= 0 || !reason) && process.env.GITHUB_EVENT_PATH) { @@ -412,7 +493,12 @@ async function main() { reason = reason || "UNKNOWN"; const api = createApi(token); - const result = await handleDequeue({ api, repository, number, reason, dryRun: process.env.DRY_RUN === "true" }); + const ref = requiredEnv("DEFAULT_BRANCH"); + const requeue = async ({ headSha }) => { + await dispatchRequeue(api, repository, { number, headSha, ref }); + return "dispatch"; + }; + const result = await handleDequeue({ api, repository, number, reason, dryRun: process.env.DRY_RUN === "true", requeue }); const summary = `merge-queue-dequeue: #${number} reason=${reason} → ${result.action}${result.dryRun ? " (dry-run)" : ""}${result.stack !== null ? ` (스택 ${result.stack}, ${result.enqueueMethod ?? "판정만"})` : ""}`; console.log(summary); if (process.env.GITHUB_STEP_SUMMARY) { diff --git a/.github/scripts/handle-merge-queue-dequeue.test.mjs b/.github/scripts/handle-merge-queue-dequeue.test.mjs index 670a79d9aa..f75dabb935 100644 --- a/.github/scripts/handle-merge-queue-dequeue.test.mjs +++ b/.github/scripts/handle-merge-queue-dequeue.test.mjs @@ -7,9 +7,13 @@ import { collectFailedJobs, countRequeuesForHead, decide, + dispatchRequeue, provenInfrastructureJobNames, enqueueStacked, handleDequeue, + renderMissingQueueTokenComment, + requeueDispatched, + REQUEUE_WORKFLOW_FILE, isStackBottom, isStackMember, lastEnqueuedAt, @@ -225,6 +229,7 @@ function fakeApi({ if (jobsMatch) return { jobs: jobs[jobsMatch[1]] ?? [] }; if (apiPath.includes("/comments") && (options.method ?? "GET") === "GET") return comments; if (apiPath.includes("/comments") && options.method === "POST") return { id: 1 }; + if (apiPath.endsWith("/dispatches") && options.method === "POST") return null; throw new Error(`unexpected ${apiPath}`); }; api.calls = calls; @@ -605,3 +610,105 @@ test("워크플로는 default branch 정의로 dequeued 에만 반응하고, 테 assert.match(source, /DEQUEUE_REASON: \$\{\{ github\.event\.reason \}\}/); assert.match(source, /cancel-in-progress: false/); }); + +// ---- 재투입은 사람 토큰을 가진 워크플로가 한다 (1005 #2208) ---- +// GITHUB_TOKEN 으로 넣은 큐 항목은 merge_group CI 를 띄우지 못해 60분 뒤 CI_TIMEOUT 으로 빠진다. + +const dispatches = (api) => api.calls.filter((call) => call.apiPath.endsWith("/dispatches")); + +test("방출 처리에 재투입 위임을 주면 마커를 먼저 남기고 직접 넣지 않는다", async () => { + for (const live of [openLive, stackedLive]) { + const api = fakeApi({ live }); + const delegated = []; + const result = await handleDequeue({ + api, repository: REPO, number: 1509, reason: "CI_TIMEOUT", logger: silent, + requeue: async (request) => { + delegated.push(request); + assert.equal(commentPosts(api).length, 1, "마커 코멘트가 위임보다 먼저다"); + return "dispatch"; + }, + }); + assert.equal(result.action, "requeue"); + assert.equal(result.enqueueMethod, "dispatch"); + assert.equal(delegated.length, 1); + assert.equal(delegated[0].number, 1509); + assert.equal(delegated[0].headSha, HEAD); + assert.ok(commentPosts(api)[0].body.body.startsWith(requeueMarker(HEAD))); + assert.equal(enqueueMutations(api).length, 0); + assert.equal(mergeAsyncCalls(api).length, 0); + } +}); + +test("재투입 위임은 재투입 판정일 때만 부른다", async () => { + const runs = [{ id: 7, name: "Unit Test", html_url: "https://x/runs/7", head_branch: queueBranchPrefix("develop", 1509) + "abc", created_at: "2026-09-04T08:05:00Z" }]; + const jobs = { 7: [{ name: "Run Unit Tests", conclusion: "failure", html_url: "https://x/jobs/71" }] }; + const cases = [ + fakeApi({ live: openLive, runs, jobs }), + fakeApi({ live: openLive, comments: [{ body: renderRequeueComment({ reason: "X", headSha: HEAD }) }] }), + fakeApi({ live: { ...openLive, state: "MERGED" } }), + ]; + for (const api of cases) { + await handleDequeue({ + api, repository: REPO, number: 1509, reason: "CI_TIMEOUT", logger: silent, + requeue: async () => assert.fail("재투입 판정이 아니면 위임하지 않는다"), + }); + } +}); + +test("dispatchRequeue 는 판정한 head 를 입력으로 재투입 워크플로를 띄운다", async () => { + const api = fakeApi({ live: openLive }); + await dispatchRequeue(api, REPO, { number: 2208, headSha: HEAD, ref: "develop" }); + assert.deepEqual(dispatches(api).map(({ apiPath, method, body }) => ({ apiPath, method, body })), [{ + apiPath: `/repos/${REPO}/actions/workflows/${REQUEUE_WORKFLOW_FILE}/dispatches`, + method: "POST", + body: { ref: "develop", inputs: { pull_request_number: "2208", head_sha: HEAD } }, + }]); +}); + +test("requeueDispatched 는 판정한 head 그대로 열려 있고 큐 밖일 때만 넣는다", async () => { + const plain = fakeApi({ live: openLive }); + assert.deepEqual(await requeueDispatched({ api: plain, repository: REPO, number: 2208, headSha: HEAD, logger: silent }), { action: "requeue", enqueueMethod: "graphql" }); + assert.equal(enqueueMutations(plain).length, 1); + + const stacked = fakeApi({ live: stackedLive }); + const stackedResult = await requeueDispatched({ api: stacked, repository: REPO, number: 2123, headSha: HEAD, logger: silent, sleep: immediateSleep }); + assert.equal(stackedResult.enqueueMethod, "merge-async"); + assert.equal(enqueueMutations(stacked).length, 0); + + for (const [live, why] of [ + [{ ...openLive, state: "MERGED" }, "MERGED"], + [{ ...openLive, mergeQueueEntry: { state: "QUEUED", position: 1 } }, "already-queued"], + [{ ...openLive, headRefOid: "f".repeat(40) }, "head-changed"], + [stackUpperLive, "stack-upper"], + ]) { + const api = fakeApi({ live }); + assert.deepEqual(await requeueDispatched({ api, repository: REPO, number: 2208, headSha: HEAD, logger: silent }), { action: "none", why }); + assert.equal(enqueueMutations(api).length, 0, why); + assert.equal(mergeAsyncCalls(api).length, 0, why); + assert.equal(commentPosts(api).length, 0, "판정·코멘트는 방출 처리 job 몫이다"); + } +}); + +test("사람 토큰이 없으면 GITHUB_TOKEN 으로 넣지 않고 직접 투입 명령을 안내한다", () => { + const body = renderMissingQueueTokenComment({ headSha: HEAD, repository: REPO, number: 2208 }); + assert.match(body, /MERGE_QUEUE_TOKEN/); + assert.match(body, /CI_TIMEOUT/); + assert.match(body, /gh pr merge 2208 --repo Afternote\/Afternote-FE/); + assert.ok(!body.includes("