diff --git a/.github/scripts/create-ci-config.test.mjs b/.github/scripts/create-ci-config.test.mjs index cfa1e077e8..ccb49950a5 100644 --- a/.github/scripts/create-ci-config.test.mjs +++ b/.github/scripts/create-ci-config.test.mjs @@ -126,16 +126,20 @@ test("keeps pull request validation secretless and release credentials isolated" } // pull_request_target은 default branch 정의에 쓰기 권한을 줄 수 있어 원칙적으로 금지한다. - // 유일한 예외는 닫힌 스택 멤버를 알리는 이 좁은 bridge이며, PR code/artifact/cache를 - // 실행하지 않고 default branch 정책과 API JSON만 읽는 불변식을 함께 고정한다. - assert.deepEqual(pullRequestTargetWorkflows, ["stack-integrity-notify.yml"]); - const stackNotifier = workflows.get("stack-integrity-notify.yml"); - assert.match(stackNotifier, /^permissions: \{\}$/m); - assert.match(stackNotifier, /ref: \$\{\{ github\.event\.repository\.default_branch \}\}/); - assert.match(stackNotifier, /persist-credentials: false/); - assert.doesNotMatch(stackNotifier, /github\.event\.pull_request\.head\.(sha|ref)/); - assert.doesNotMatch(stackNotifier, /actions\/(download-artifact|cache)@/); - assert.doesNotMatch(stackNotifier, /\bsecrets(?:\.|\[)/); + // 예외는 PR code/artifact/cache를 실행하지 않고 default branch 정책과 API JSON만 읽는 좁은 + // bridge 둘뿐이다 — 닫힌 스택 멤버 알림과 merge queue 방출 처리(#1892). 그 불변식을 둘 다에 + // 함께 고정한다. + const narrowBridges = ["merge-queue-dequeue.yml", "stack-integrity-notify.yml"]; + assert.deepEqual([...pullRequestTargetWorkflows].sort(), narrowBridges); + for (const bridgeName of narrowBridges) { + const bridge = workflows.get(bridgeName); + assert.match(bridge, /^permissions: \{\}$/m, bridgeName); + assert.match(bridge, /ref: \$\{\{ github\.event\.repository\.default_branch \}\}/, bridgeName); + assert.match(bridge, /persist-credentials: false/, bridgeName); + assert.doesNotMatch(bridge, /github\.event\.pull_request\.head\.(sha|ref)/, bridgeName); + assert.doesNotMatch(bridge, /actions\/(download-artifact|cache)@/, bridgeName); + assert.doesNotMatch(bridge, /\bsecrets(?:\.|\[)/, bridgeName); + } const actionReference = "uses: ./.github/actions/setup-ci-config"; for (const name of [ diff --git a/.github/scripts/handle-merge-queue-dequeue.mjs b/.github/scripts/handle-merge-queue-dequeue.mjs new file mode 100644 index 0000000000..1ff578ce31 --- /dev/null +++ b/.github/scripts/handle-merge-queue-dequeue.mjs @@ -0,0 +1,291 @@ +#!/usr/bin/env node + +// merge queue 에서 방출된 PR 을 자동 처리한다 (#1892). +// +// 큐(#1477)는 투입한 뒤의 결과가 «보러 가야만» 보였다. 방출은 두 갈래다. +// - merge group CI 실패 — PR 화면엔 «removed from the merge queue» 만 남고 실패 job 은 Actions 탭에서 +// event:merge_group 으로 따로 찾아야 했다. → 실패 job 링크를 PR 코멘트로 남긴다. 재투입하지 않는다. +// - 조용한 방출 — 앞 건이 머지되며 base 가 움직이면 실패 흔적 없이 큐에서 빠진다(0830 실측 #1509: +// mergeQueueEntry null · mergeStateStatus CLEAN · merge_group run 전량 success). → 같은 head SHA 당 +// 한 번만 재투입한다. 0902 에 감시 루프가 90초마다 맹목 재투입해 30분을 헛돈 사고(#1638·#1639)가 +// «한 번까지» 규칙의 근거다. 횟수는 이 스크립트가 남기는 마커 코멘트로 센다. +// +// 판정 순서는 «실패 job 이 있는가» 가 먼저다. payload 의 reason 문자열은 GitHub 이 열거값을 문서화하지 +// 않아 정본으로 삼지 않는다 — 실패 job 은 데이터로 확인하고, reason 은 «사람이 뺐다·이미 머지됐다» 를 +// 거르는 데만 쓴다. 그 밖의 reason 은 전부 조용한 방출로 본다. + +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import process from "node:process"; +import { pathToFileURL } from "node:url"; + +export const REQUEUE_MARKER_PREFIX = "`; +} + +/** 같은 head 에서 이미 재투입한 횟수 — 마커 코멘트 수다. 새 커밋이 올라오면 head 가 바뀌어 0 부터다. */ +export function countRequeuesForHead(comments, headSha) { + const marker = requeueMarker(headSha); + return comments.filter((comment) => typeof comment.body === "string" && comment.body.includes(marker)).length; +} + +/** + * 판정표. + * 1. 실패 job 이 있다 → comment-failure (재투입 없음) + * 2. reason 이 MANUAL·ALREADY_MERGED·MERGE → none + * 3. 같은 head 에서 이미 재투입했다 → comment-give-up + * 4. 그 밖 → requeue + */ +export function decide({ reason, failedJobs, requeueCount }) { + if (failedJobs.length > 0) return "comment-failure"; + if (NO_ACTION_REASONS.has(String(reason ?? "").toUpperCase())) return "none"; + if (requeueCount >= 1) return "comment-give-up"; + return "requeue"; +} + +const shortSha = (sha) => sha.slice(0, 7); + +export function renderFailureComment({ reason, headSha, failedJobs, repository, number }) { + const lines = failedJobs.map((job) => `- [${job.runName} / ${job.jobName}](${job.jobUrl}) — ${job.conclusion}`); + return [ + "### merge queue 방출 — merge group CI 실패", + "", + `사유 \`${reason}\` · head \`${shortSha(headSha)}\`. 재투입하지 않았다.`, + "", + "실패한 job:", + ...lines, + "", + `고친 뒤 \`gh pr merge ${number} --repo ${repository}\` 로 다시 투입한다.`, + ].join("\n"); +} + +export function renderRequeueComment({ reason, headSha }) { + return [ + requeueMarker(headSha), + "### merge queue 방출 — 재투입했다", + "", + `사유 \`${reason}\` · head \`${shortSha(headSha)}\` · merge group 실패 job 없음 → 조용한 방출로 보고 한 번 재투입했다.`, + "같은 head 에서 또 방출되면 재투입하지 않고 여기에 남긴다.", + ].join("\n"); +} + +export function renderGiveUpComment({ reason, headSha }) { + return [ + "### merge queue 방출 — 같은 head 두 번째, 재투입하지 않는다", + "", + `사유 \`${reason}\` · head \`${shortSha(headSha)}\`. 실패 job 은 없다.`, + "`gh run list --event merge_group` 으로 merge group run 을 확인하고 수동으로 투입한다.", + ].join("\n"); +} + +export function createApi(token, { fetchImpl = globalThis.fetch } = {}) { + if (typeof fetchImpl !== "function") { + throw new TypeError("fetch 구현이 필요합니다."); + } + + return async function api(apiPath, { method = "GET", body } = {}) { + const response = await fetchImpl(`https://api.github.com${apiPath}`, { + method, + headers: { + accept: "application/vnd.github+json", + authorization: `Bearer ${token}`, + "content-type": "application/json", + "x-github-api-version": "2022-11-28", + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + if (!response.ok) { + const detail = await response.text(); + throw new Error(`GitHub API ${method} ${apiPath} 실패: ${response.status} ${detail}`); + } + if (response.status === 204) return null; + return response.json(); + }; +} + +async function graphql(api, query, variables) { + const payload = await api("/graphql", { method: "POST", body: { query, variables } }); + if (payload?.errors?.length) { + throw new Error(`GraphQL 실패: ${JSON.stringify(payload.errors)}`); + } + return payload.data; +} + +/** 이벤트는 runner 대기 중 순서가 뒤집힐 수 있으므로 payload 가 아니라 live 상태를 정본으로 삼는다. */ +export async function fetchLivePullRequest(api, repository, number) { + const [owner, name] = repository.split("/"); + const data = await graphql( + api, + `query($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + id + state + headRefOid + baseRefName + mergeQueueEntry { state position } + timelineItems(last: 5, itemTypes: [ADDED_TO_MERGE_QUEUE_EVENT]) { + nodes { ... on AddedToMergeQueueEvent { createdAt } } + } + } + } + }`, + { owner, name, number }, + ); + return data.repository.pullRequest; +} + +/** 마지막으로 큐에 들어간 시각 — 그 뒤의 merge_group run 만 이번 방출의 근거다. */ +export function lastEnqueuedAt(pullRequest) { + const stamps = (pullRequest.timelineItems?.nodes ?? []).map((node) => node?.createdAt).filter(Boolean); + return stamps.length > 0 ? stamps[stamps.length - 1] : undefined; +} + +export async function fetchQueueFailedJobs(api, repository, { baseRef, number, since }) { + const prefix = queueBranchPrefix(baseRef, number); + const listed = await api(`/repos/${repository}/actions/runs?event=merge_group&per_page=100`); + const runs = (listed.workflow_runs ?? []) + .filter((run) => typeof run.head_branch === "string" && run.head_branch.startsWith(prefix)) + .filter((run) => !since || typeof run.created_at !== "string" || run.created_at >= since) + .slice(0, QUEUE_RUNS_TO_INSPECT); + const jobsByRunId = new Map(); + for (const run of runs) { + const jobs = await api(`/repos/${repository}/actions/runs/${run.id}/jobs?per_page=100`); + jobsByRunId.set(run.id, jobs.jobs ?? []); + } + return collectFailedJobs({ runs, jobsByRunId, baseRef, number, since }); +} + +export async function fetchComments(api, repository, number) { + const comments = []; + for (let page = 1; page <= 5; page += 1) { + const batch = await api(`/repos/${repository}/issues/${number}/comments?per_page=100&page=${page}`); + comments.push(...batch); + if (batch.length < 100) break; + } + return comments; +} + +export async function postComment(api, repository, number, body) { + await api(`/repos/${repository}/issues/${number}/comments`, { method: "POST", body: { body } }); +} + +export async function enqueue(api, pullRequestId) { + const data = await graphql( + api, + `mutation($pullRequestId: ID!) { + enqueuePullRequest(input: { pullRequestId: $pullRequestId }) { + mergeQueueEntry { state position } + } + }`, + { pullRequestId }, + ); + return data.enqueuePullRequest.mergeQueueEntry; +} + +export async function handleDequeue({ api, repository, number, reason, dryRun = false, logger = console }) { + const live = await fetchLivePullRequest(api, repository, number); + if (!live) throw new Error(`#${number} 를 찾을 수 없습니다`); + if (live.state !== "OPEN") { + logger.log(`#${number} 는 ${live.state} — 아무것도 하지 않는다`); + return { action: "none", why: live.state }; + } + if (live.mergeQueueEntry) { + logger.log(`#${number} 는 이미 큐에 있다(${live.mergeQueueEntry.state}) — 아무것도 하지 않는다`); + return { action: "none", why: "already-queued" }; + } + + const headSha = live.headRefOid; + const since = lastEnqueuedAt(live); + const failedJobs = await fetchQueueFailedJobs(api, repository, { baseRef: live.baseRefName, number, since }); + const comments = await fetchComments(api, repository, number); + const requeueCount = countRequeuesForHead(comments, headSha); + const action = decide({ reason, failedJobs, requeueCount }); + logger.log(`#${number} reason=${reason} head=${shortSha(headSha)} 마지막투입=${since ?? "?"} 실패job=${failedJobs.length} 재투입횟수=${requeueCount} → ${action}`); + + if (dryRun) return { action, dryRun: true, failedJobs, requeueCount }; + + if (action === "comment-failure") { + await postComment(api, repository, number, renderFailureComment({ reason, headSha, failedJobs, repository, number })); + } else if (action === "comment-give-up") { + await postComment(api, repository, number, renderGiveUpComment({ reason, headSha })); + } else if (action === "requeue") { + // 마커를 먼저 남긴다 — 재투입 뒤 코멘트가 실패하면 다음 방출에서 두 번째 재투입이 나간다. + await postComment(api, repository, number, renderRequeueComment({ reason, headSha })); + const entry = await enqueue(api, live.id); + logger.log(`#${number} 재투입 → ${entry?.state ?? "?"} position=${entry?.position ?? "?"}`); + } + return { action, failedJobs, requeueCount }; +} + +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 가 필요합니다."); + } + 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) { + const event = JSON.parse(await readFile(process.env.GITHUB_EVENT_PATH, "utf8")); + number = Number.isInteger(number) && number > 0 ? number : Number(event.number ?? event.pull_request?.number); + reason = reason || event.reason; + } + if (!Number.isInteger(number) || number <= 0) { + throw new Error("PULL_REQUEST_NUMBER 가 양의 정수여야 합니다."); + } + reason = reason || "UNKNOWN"; + + const api = createApi(token); + const result = await handleDequeue({ api, repository, number, reason, dryRun: process.env.DRY_RUN === "true" }); + const summary = `merge-queue-dequeue: #${number} reason=${reason} → ${result.action}${result.dryRun ? " (dry-run)" : ""}`; + console.log(summary); + if (process.env.GITHUB_STEP_SUMMARY) { + const { appendFile } = await import("node:fs/promises"); + await appendFile(process.env.GITHUB_STEP_SUMMARY, `${summary}\n`); + } +} + +const invokedPath = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : ""; +if (import.meta.url === invokedPath) { + main().catch((error) => { + console.error(error.message); + process.exitCode = 1; + }); +} diff --git a/.github/scripts/handle-merge-queue-dequeue.test.mjs b/.github/scripts/handle-merge-queue-dequeue.test.mjs new file mode 100644 index 0000000000..f09f58d9a2 --- /dev/null +++ b/.github/scripts/handle-merge-queue-dequeue.test.mjs @@ -0,0 +1,208 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { + NO_ACTION_REASONS, + collectFailedJobs, + countRequeuesForHead, + decide, + handleDequeue, + lastEnqueuedAt, + queueBranchPrefix, + renderFailureComment, + renderRequeueComment, + requeueMarker, +} from "./handle-merge-queue-dequeue.mjs"; + +const REPO = "Afternote/Afternote-FE"; +const HEAD = "0123456789abcdef0123456789abcdef01234567"; +const silent = { log() {} }; + +// ---- 판정표 (#1892) ---- + +test("실패 job 이 있으면 reason 과 무관하게 comment-failure — 재투입하지 않는다", () => { + const failedJobs = [{ jobName: "Run Unit Tests", conclusion: "failure" }]; + for (const reason of ["CI_FAILURE", "UNKNOWN_REMOVAL_REASON", "MANUAL", "ROLL_BACK"]) { + assert.equal(decide({ reason, failedJobs, requeueCount: 0 }), "comment-failure", reason); + } +}); + +test("MANUAL·ALREADY_MERGED·MERGE(D) 는 표기와 무관하게 무동작", () => { + for (const reason of [...NO_ACTION_REASONS, "merged", "manual"]) { + assert.equal(decide({ reason, failedJobs: [], requeueCount: 0 }), "none", reason); + } + assert.equal(decide({ reason: "failed_checks", failedJobs: [], requeueCount: 0 }), "requeue"); +}); + +test("실패 job 없는 방출은 같은 head 에서 한 번만 재투입하고, 두 번째는 comment-give-up", () => { + assert.equal(decide({ reason: "UNKNOWN_REMOVAL_REASON", failedJobs: [], requeueCount: 0 }), "requeue"); + assert.equal(decide({ reason: "UNKNOWN_REMOVAL_REASON", failedJobs: [], requeueCount: 1 }), "comment-give-up"); + assert.equal(decide({ reason: "ROLL_BACK", failedJobs: [], requeueCount: 3 }), "comment-give-up"); +}); + +// ---- 실패 job 수집 ---- + +test("이 PR 의 큐 브랜치 run 에서 failure·timed_out job 만 모은다 — 다른 PR 의 run, cancelled job 은 뺀다", () => { + const runs = [ + { id: 1, name: "Unit Test", html_url: "https://x/runs/1", head_branch: queueBranchPrefix("develop", 1509) + "aaaa" }, + { id: 2, name: "Screenshot", html_url: "https://x/runs/2", head_branch: queueBranchPrefix("develop", 1509) + "aaaa" }, + { id: 3, name: "Unit Test", html_url: "https://x/runs/3", head_branch: queueBranchPrefix("develop", 1510) + "aaaa" }, + { id: 4, name: "Lint", html_url: "https://x/runs/4", head_branch: "refs/pull/1509/merge" }, + ]; + const jobsByRunId = new Map([ + [1, [ + { name: "Run Unit Tests", conclusion: "failure", html_url: "https://x/jobs/11" }, + { name: "Kover", conclusion: "cancelled", html_url: "https://x/jobs/12" }, + ]], + [2, [{ name: "Validate", conclusion: "timed_out", html_url: "https://x/jobs/21" }]], + [3, [{ name: "Run Unit Tests", conclusion: "failure", html_url: "https://x/jobs/31" }]], + [4, [{ name: "ktlint", conclusion: "failure", html_url: "https://x/jobs/41" }]], + ]); + const failed = collectFailedJobs({ runs, jobsByRunId, baseRef: "develop", number: 1509 }); + assert.deepEqual(failed.map((job) => job.jobUrl), ["https://x/jobs/11", "https://x/jobs/21"]); + assert.equal(failed[0].runName, "Unit Test"); + assert.equal(failed[1].conclusion, "timed_out"); +}); + +test("마지막 투입 이전의 run 은 이번 방출의 근거가 아니다 — 큐 브랜치 이름은 base SHA 라 옛 실패가 같은 접두어로 잡힌다", () => { + const branch = queueBranchPrefix("develop", 1582) + "aaaa"; + const runs = [ + { id: 1, name: "Android Managed Device Test", html_url: "https://x/runs/1", head_branch: branch, created_at: "2026-09-04T08:06:21Z" }, + { id: 2, name: "Android Managed Device Test", html_url: "https://x/runs/2", head_branch: branch, created_at: "2026-09-04T12:00:00Z" }, + ]; + const jobsByRunId = new Map([ + [1, [{ name: "api34", conclusion: "failure", html_url: "https://x/jobs/1" }]], + [2, [{ name: "api34", conclusion: "success", html_url: "https://x/jobs/2" }]], + ]); + assert.equal(collectFailedJobs({ runs, jobsByRunId, baseRef: "develop", number: 1582 }).length, 1); + assert.equal(collectFailedJobs({ runs, jobsByRunId, baseRef: "develop", number: 1582, since: "2026-09-04T11:00:00Z" }).length, 0); + assert.equal(lastEnqueuedAt({ timelineItems: { nodes: [{ createdAt: "2026-09-04T08:06:00Z" }, { createdAt: "2026-09-04T11:00:00Z" }] } }), "2026-09-04T11:00:00Z"); + assert.equal(lastEnqueuedAt({ timelineItems: { nodes: [] } }), undefined); +}); + +// ---- 재투입 횟수 = 같은 head 의 마커 코멘트 수 ---- + +test("재투입 횟수는 같은 head 의 마커 코멘트만 센다 — 새 커밋이 올라오면 0 부터다", () => { + const comments = [ + { body: renderRequeueComment({ reason: "UNKNOWN_REMOVAL_REASON", headSha: HEAD }) }, + { body: "그냥 코멘트" }, + { body: renderRequeueComment({ reason: "ROLL_BACK", headSha: "f".repeat(40) }) }, + ]; + assert.equal(countRequeuesForHead(comments, HEAD), 1); + assert.equal(countRequeuesForHead(comments, "f".repeat(40)), 1); + assert.equal(countRequeuesForHead(comments, "0".repeat(40)), 0); + assert.ok(renderRequeueComment({ reason: "X", headSha: HEAD }).startsWith(requeueMarker(HEAD))); +}); + +test("실패 코멘트는 job 링크와 재투입 명령을 담고, 재투입했다고 말하지 않는다", () => { + const body = renderFailureComment({ + reason: "CI_FAILURE", + headSha: HEAD, + repository: REPO, + number: 1509, + failedJobs: [{ runName: "Unit Test", jobName: "Run Unit Tests", jobUrl: "https://x/jobs/11", conclusion: "failure" }], + }); + assert.match(body, /\[Unit Test \/ Run Unit Tests\]\(https:\/\/x\/jobs\/11\)/); + assert.match(body, /gh pr merge 1509 --repo Afternote\/Afternote-FE/); + assert.match(body, /재투입하지 않았다/); + assert.ok(!body.includes(requeueMarker(HEAD))); +}); + +// ---- handleDequeue 흐름: live 상태가 정본, 쓰기 순서 ---- + +function fakeApi({ live, runs = [], jobs = {}, comments = [] }) { + const calls = []; + const api = async (apiPath, options = {}) => { + calls.push({ apiPath, method: options.method ?? "GET", body: options.body }); + if (apiPath === "/graphql") { + const query = options.body.query; + if (query.includes("enqueuePullRequest")) { + return { data: { enqueuePullRequest: { mergeQueueEntry: { state: "QUEUED", position: 1 } } } }; + } + return { data: { repository: { pullRequest: live } } }; + } + if (apiPath.startsWith(`/repos/${REPO}/actions/runs?`)) return { workflow_runs: runs }; + const jobsMatch = /\/actions\/runs\/(\d+)\/jobs/.exec(apiPath); + 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 }; + throw new Error(`unexpected ${apiPath}`); + }; + api.calls = calls; + return api; +} + +const openLive = { + id: "PR_1", + state: "OPEN", + headRefOid: HEAD, + baseRefName: "develop", + mergeQueueEntry: null, + timelineItems: { nodes: [{ createdAt: "2026-09-04T08:00:00Z" }] }, +}; + +test("MERGED·CLOSED 거나 이미 큐에 있으면 payload 와 무관하게 아무것도 쓰지 않는다", async () => { + for (const live of [ + { ...openLive, state: "MERGED" }, + { ...openLive, state: "CLOSED" }, + { ...openLive, mergeQueueEntry: { state: "QUEUED", position: 2 } }, + ]) { + const api = fakeApi({ live }); + const result = await handleDequeue({ api, repository: REPO, number: 1509, reason: "UNKNOWN_REMOVAL_REASON", logger: silent }); + assert.equal(result.action, "none"); + assert.equal(api.calls.filter((call) => call.method === "POST" && call.apiPath !== "/graphql").length, 0); + assert.ok(!api.calls.some((call) => call.body?.query?.includes("enqueuePullRequest"))); + } +}); + +test("조용한 방출: 마커 코멘트를 먼저 남기고 그 다음 enqueuePullRequest 를 쏜다", async () => { + const api = fakeApi({ live: openLive }); + const result = await handleDequeue({ api, repository: REPO, number: 1509, reason: "UNKNOWN_REMOVAL_REASON", logger: silent }); + assert.equal(result.action, "requeue"); + const writes = api.calls.filter((call) => call.method === "POST" && (call.apiPath !== "/graphql" || call.body.query.includes("enqueuePullRequest"))); + assert.equal(writes.length, 2); + assert.match(writes[0].apiPath, /\/issues\/1509\/comments$/); + assert.ok(writes[0].body.body.startsWith(requeueMarker(HEAD))); + assert.equal(writes[1].apiPath, "/graphql"); +}); + +test("같은 head 의 두 번째 방출은 코멘트만 남기고 enqueuePullRequest 를 쏘지 않는다", async () => { + const api = fakeApi({ live: openLive, comments: [{ body: renderRequeueComment({ reason: "X", headSha: HEAD }) }] }); + const result = await handleDequeue({ api, repository: REPO, number: 1509, reason: "UNKNOWN_REMOVAL_REASON", logger: silent }); + assert.equal(result.action, "comment-give-up"); + assert.ok(!api.calls.some((call) => call.body?.query?.includes("enqueuePullRequest"))); + assert.equal(api.calls.filter((call) => call.method === "POST" && call.apiPath.endsWith("/comments")).length, 1); +}); + +test("CI 실패: 실패 job 링크 코멘트만, 재투입 없음", 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 api = fakeApi({ live: openLive, runs, jobs }); + const result = await handleDequeue({ api, repository: REPO, number: 1509, reason: "CI_FAILURE", logger: silent }); + assert.equal(result.action, "comment-failure"); + const comment = api.calls.find((call) => call.method === "POST" && call.apiPath.endsWith("/comments")); + assert.match(comment.body.body, /https:\/\/x\/jobs\/71/); + assert.ok(!api.calls.some((call) => call.body?.query?.includes("enqueuePullRequest"))); +}); + +test("dry-run 은 판정만 하고 아무것도 쓰지 않는다", async () => { + const api = fakeApi({ live: openLive }); + const result = await handleDequeue({ api, repository: REPO, number: 1509, reason: "ROLL_BACK", dryRun: true, logger: silent }); + assert.equal(result.action, "requeue"); + assert.equal(api.calls.filter((call) => call.method === "POST" && call.apiPath !== "/graphql").length, 0); + assert.ok(!api.calls.some((call) => call.body?.query?.includes("enqueuePullRequest"))); +}); + +// ---- 워크플로 정책 ---- + +test("워크플로는 default branch 정의로 dequeued 에만 반응하고, 테스트를 먼저 돌린 뒤 스크립트를 부른다", () => { + const source = readFileSync(new URL("../workflows/merge-queue-dequeue.yml", import.meta.url), "utf8"); + assert.match(source, /^on:\n\s{2}pull_request_target:\n\s{4}types: \[dequeued\]$/m); + assert.match(source, /^permissions: \{\}$/m); + assert.match(source, /ref: \$\{\{ github\.event\.repository\.default_branch \}\}/); + assert.match(source, /persist-credentials: false/); + assert.ok(source.indexOf("handle-merge-queue-dequeue.test.mjs") < source.indexOf("node .github/scripts/handle-merge-queue-dequeue.mjs")); + assert.match(source, /DEQUEUE_REASON: \$\{\{ github\.event\.reason \}\}/); + assert.match(source, /cancel-in-progress: false/); +}); diff --git a/.github/workflows/merge-queue-dequeue.yml b/.github/workflows/merge-queue-dequeue.yml new file mode 100644 index 0000000000..1ecb7f86bb --- /dev/null +++ b/.github/workflows/merge-queue-dequeue.yml @@ -0,0 +1,47 @@ +# merge queue 방출 자동 처리 (#1892) +# +# pull_request_target 은 default branch 의 workflow 정의만 실행한다. PR 코드·artifact·cache 를 +# 체크아웃하거나 실행하지 않고, GitHub API 응답과 default branch 의 정책 스크립트만 쓴다 +# (stack-integrity-notify.yml 과 같은 경계). +# +# 판정은 handle-merge-queue-dequeue.mjs 가 한다 — 이 PR 의 큐 브랜치 merge_group run 에 실패 job 이 +# 있으면 링크를 코멘트로 남기고, 없으면(조용한 방출) 같은 head 당 한 번만 재투입하고, +# MANUAL·ALREADY_MERGED·MERGE 는 아무것도 하지 않는다. +name: merge-queue-dequeue + +on: + pull_request_target: + types: [dequeued] + +permissions: {} + +jobs: + handle: + # 같은 PR 의 방출 이벤트가 겹쳐도 줄을 세운다 — 취소하면 마커 코멘트 없이 재투입만 남을 수 있다. + concurrency: + group: merge-queue-dequeue-${{ github.repository_id }}-${{ github.event.pull_request.number }} + cancel-in-progress: false + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + actions: read + contents: write + issues: write + pull-requests: write + steps: + - name: Clone dequeue policy from the default branch + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.repository.default_branch }} + persist-credentials: false + + - name: Run script tests + run: node --test .github/scripts/handle-merge-queue-dequeue.test.mjs + + - name: 실패 job 안내 또는 한 번 재투입 + env: + GITHUB_TOKEN: ${{ github.token }} + GITHUB_REPOSITORY: ${{ github.repository }} + PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }} + DEQUEUE_REASON: ${{ github.event.reason }} + run: node .github/scripts/handle-merge-queue-dequeue.mjs