Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions .github/scripts/find-viewed-auto-merge-targets.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -112,13 +112,18 @@ test("워크플로는 해제 대상의 예약을 끄고 큐에서 빼며, 해제
assert.ok(workflow.indexOf("--disable-auto") < workflow.indexOf("--match-head-commit"));
});

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).
// 해제 단계는 CI 가 필요 없어 GITHUB_TOKEN 을 써도 되므로, 예약 단계만 본다.
const enableStep = workflow.slice(workflow.indexOf("- name: Enable auto-merge"));
assert.match(enableStep, /GH_TOKEN: \$\{\{ secrets\.MERGE_QUEUE_TOKEN \}\}/);
assert.doesNotMatch(enableStep, /GH_TOKEN: \$\{\{ github\.token \}\}/);
assert.match(enableStep, /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/);
Expand Down
122 changes: 104 additions & 18 deletions .github/scripts/handle-merge-queue-dequeue.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand All @@ -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} 를 찾을 수 없습니다`);
Expand Down Expand Up @@ -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) {
Expand All @@ -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) {
Expand Down
107 changes: 107 additions & 0 deletions .github/scripts/handle-merge-queue-dequeue.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,13 @@ import {
collectFailedJobs,
countRequeuesForHead,
decide,
dispatchRequeue,
provenInfrastructureJobNames,
enqueueStacked,
handleDequeue,
renderMissingQueueTokenComment,
requeueDispatched,
REQUEUE_WORKFLOW_FILE,
isStackBottom,
isStackMember,
lastEnqueuedAt,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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("<!-- merge-queue-dequeue:requeued"));
});

test("워크플로: 방출 처리는 시크릿 없이 dispatch 만 하고, 재투입은 workflow_dispatch 로만 사람 토큰을 쓴다", () => {
const dequeue = readFileSync(new URL("../workflows/merge-queue-dequeue.yml", import.meta.url), "utf8");
assert.doesNotMatch(dequeue, /\bsecrets(?:\.|\[)/);
assert.match(dequeue, /actions: write/);
assert.match(dequeue, /DEFAULT_BRANCH: \$\{\{ github\.event\.repository\.default_branch \}\}/);

const requeue = readFileSync(new URL(`../workflows/${REQUEUE_WORKFLOW_FILE}`, import.meta.url), "utf8");
assert.match(requeue, /^on:\n workflow_dispatch:\n/m);
assert.doesNotMatch(requeue, /^ (pull_request|pull_request_target|merge_group|schedule|push):/m);
assert.match(requeue, /^permissions: \{\}$/m);
assert.match(requeue, /MERGE_QUEUE_TOKEN: \$\{\{ secrets\.MERGE_QUEUE_TOKEN \}\}/);
assert.match(requeue, /MODE: requeue/);
assert.match(requeue, /ref: \$\{\{ github\.event\.repository\.default_branch \}\}/);
assert.match(requeue, /persist-credentials: false/);
});
9 changes: 7 additions & 2 deletions .github/workflows/merge-queue-dequeue.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
# 판정은 handle-merge-queue-dequeue.mjs 가 한다 — 이 PR 의 큐 브랜치 merge_group run 에 실패 job 이
# 있으면 링크를 코멘트로 남기고, 없으면(조용한 방출) 같은 head 당 한 번만 재투입하고,
# MANUAL·ALREADY_MERGED·MERGE 는 아무것도 하지 않는다.
#
# 재투입은 여기서 하지 않고 merge-queue-requeue.yml 을 dispatch 한다. GITHUB_TOKEN 으로 넣은 큐 항목은
# merge_group CI 를 띄우지 못하고, 이 브리지는 시크릿을 쓰지 않으므로 사람 토큰을 가진 쪽으로 넘긴다.
name: merge-queue-dequeue

on:
Expand All @@ -24,8 +27,9 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
actions: read
contents: write
# 재투입 워크플로 dispatch 에 쓴다.
actions: write
contents: read
issues: write
pull-requests: write
steps:
Expand All @@ -44,4 +48,5 @@ jobs:
GITHUB_REPOSITORY: ${{ github.repository }}
PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }}
DEQUEUE_REASON: ${{ github.event.reason }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
run: node .github/scripts/handle-merge-queue-dequeue.mjs
Loading
Loading