diff --git a/.github/scripts/dependabot-pr-gate.mjs b/.github/scripts/dependabot-pr-gate.mjs index 58b0e7a7b..63548b51b 100644 --- a/.github/scripts/dependabot-pr-gate.mjs +++ b/.github/scripts/dependabot-pr-gate.mjs @@ -6,8 +6,17 @@ // 를 여기서 한다. mode 는 사람이 정하지 않는다. `ci-test-plan.mjs` 가 변경 파일로 판정한 // 결과를 그대로 쓴다. 게이트 면제는 하지 않는다 — 봇 PR 도 워크플로 파일을 건드린다. // -// 순수 함수(needsGate·buildPlan·buildIssue·applyGate)와 GitHub 호출(main)을 갈라 두어 -// 정책은 dependabot-pr-gate.test.mjs 가 잠근다. +// 게이트는 GITHUB_TOKEN 으로 제목·본문을 고치므로 그 `edited` 는 다른 워크플로를 깨우지 못한다. +// 같은 순간 열린 PR Validation·Managed Device 는 opened 시점 페이로드를 읽어 red 로 남았다(#2212). +// 게이트가 검증을 다시 부르는 길은 막혀 있다. 같은 sha 에 dispatch 로 붙인 초록은 PR 롤업이 옛 +// `pull_request` 실패를 덮지 못하고(#1825), 재실행은 옛 페이로드를 그대로 쓰는 데다 기기 레인의 +// 1회차 인프라 복구를 잃으며, App 토큰은 이 브리지에 금지된 시크릿이 필요하다. 그래서 방향을 +// 뒤집었다. 검증이 `await` 명령으로 게이트가 채울 때까지 live PR 을 기다렸다가 그 값으로 판정한다. +// +// 순수 함수(needsGate·awaitsGate·buildPlan·buildIssue·applyGate)·대기(awaitGate)와 GitHub 호출 +// (fillGate·awaitMain)을 갈라 두어 정책은 dependabot-pr-gate.test.mjs 가 잠근다. + +import { readFile, writeFile } from "node:fs/promises"; import { inspectAndroidTestImpact } from "./ci-test-plan.mjs"; @@ -53,6 +62,59 @@ export function buildPlan(changedPaths) { }; } +/** `await` 가 live PR 을 다시 조회하는 간격. 게이트는 job 시작 뒤 수 초 안에 끝난다(#2138 실측 7초). */ +export const AWAIT_POLL_INTERVAL_MS = 5_000; + +/** + * 검증이 아직 기다려야 하는 PR 인가. dependabot 이 연 열린 PR 인데 제목에 대표 이슈 번호가 없을 때만이다. + * 마커가 아니라 제목으로 가른다. 게이트가 경계 경로에서 멈춰 사람이 손으로 채운 PR 에는 마커가 없는데, + * 그 PR 까지 edited 마다 제한 시간을 꽉 채워 기다리게 하지 않는다. 게이트는 제목과 본문을 한 번의 + * PATCH 로 바꾸므로 제목에 번호가 있으면 본문도 채워져 있다. + */ +export function awaitsGate(pullRequest) { + return ( + pullRequest?.user?.login === DEPENDABOT_LOGIN && + pullRequest?.state === "open" && + !TITLE_ISSUE_PATTERN.test(String(pullRequest?.title ?? "")) + ); +} + +/** + * 게이트가 제목·본문을 채울 때까지 live PR 을 다시 읽는다(#2212). `initial`(이벤트 페이로드의 PR)이 이미 + * 판정할 수 있는 상태면 API 를 부르지 않고 그대로 돌려준다. 게이트가 채운 뒤의 edited·synchronize 는 + * 종전처럼 페이로드만으로 끝난다. 조회가 실패하면 제한 시간 안에서 다시 시도한다. 한 번도 읽지 못한 채 + * 시간이 다하면 마지막 오류를 던지고, 읽은 적이 있으면 마지막 live 상태를 돌려준다. 판정은 호출한 + * 검증의 몫이라, 게이트가 실패했으면 검증이 평소 오류 문구로 실패한다. + */ +export async function awaitGate({ loadPullRequest, initial, timeoutMs, intervalMs = AWAIT_POLL_INTERVAL_MS }) { + if (initial && !awaitsGate(initial)) { + return { pullRequest: initial, polls: 0, timedOut: false }; + } + const deadline = Date.now() + timeoutMs; + let latest; + let lastError; + for (let polls = 1; ; polls += 1) { + try { + const pullRequest = await loadPullRequest(); + if (!awaitsGate(pullRequest)) { + return { pullRequest, polls, timedOut: false }; + } + latest = pullRequest; + lastError = undefined; + } catch (error) { + lastError = error; + } + const remaining = deadline - Date.now(); + if (remaining <= 0) { + if (latest === undefined) { + throw lastError; + } + return { pullRequest: latest, polls, timedOut: true, lastError }; + } + await new Promise((resolve) => setTimeout(resolve, Math.min(intervalMs, remaining))); + } +} + /** 대표 이슈. `.github/ISSUE_TEMPLATE/issue.yml` 양식이라 reconcile-issue-metadata 가 어사인·라벨을 맞춘다. */ export function buildIssue({ title, prNumber, changedPaths, plan }) { const summary = String(title ?? "").replace(/^chore\(deps\):\s*/i, "").trim() || `PR #${prNumber}`; @@ -153,7 +215,7 @@ async function listChangedPaths(apiUrl, repository, prNumber, token) { return paths; } -async function main() { +async function fillGate() { const token = process.env.GITHUB_TOKEN; const repository = process.env.GITHUB_REPOSITORY; const prNumber = Number(process.env.PR_NUMBER); @@ -200,8 +262,53 @@ async function main() { console.log(`#${prNumber} 제목·본문 갱신: Refs #${issueNumber}, androidTest mode=${plan.mode}`); } +/** + * `await <출력 경로>`: dependabot PR 의 `pull_request` 검증이 부른다. 게이트를 기다린 뒤 PR JSON 을 출력 + * 경로에 쓴다. 이벤트 페이로드(GITHUB_EVENT_PATH)의 PR 이 이미 판정할 수 있는 상태면 그것을 그대로 쓴다. + * 제한 시간을 넘겨도 실패하지 않고 경고만 남긴다. 판정은 뒤따르는 검증이 한다. + */ +async function awaitMain(outputPath) { + const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN; + const repository = process.env.GITHUB_REPOSITORY; + const prNumber = Number(process.env.PR_NUMBER); + const timeoutSeconds = Number(process.env.AWAIT_TIMEOUT_SECONDS); + const apiUrl = process.env.GITHUB_API_URL ?? "https://api.github.com"; + if (!outputPath || !token || !repository || !Number.isInteger(prNumber) || prNumber <= 0) { + throw new Error("await <출력 경로> 와 GITHUB_TOKEN(또는 GH_TOKEN), GITHUB_REPOSITORY, PR_NUMBER 가 필요합니다"); + } + if (!Number.isInteger(timeoutSeconds) || timeoutSeconds < 0) { + throw new Error(`AWAIT_TIMEOUT_SECONDS 는 0 이상의 정수여야 합니다: ${process.env.AWAIT_TIMEOUT_SECONDS}`); + } + + const eventPullRequest = process.env.GITHUB_EVENT_PATH + ? JSON.parse(await readFile(process.env.GITHUB_EVENT_PATH, "utf8")).pull_request + : undefined; + const { pullRequest, polls, timedOut, lastError } = await awaitGate({ + loadPullRequest: () => github(`${apiUrl}/repos/${repository}/pulls/${prNumber}`, token), + initial: eventPullRequest?.number === prNumber ? eventPullRequest : undefined, + timeoutMs: timeoutSeconds * 1000, + }); + await writeFile(outputPath, JSON.stringify(pullRequest)); + if (timedOut) { + console.log( + `::warning::dependabot-pr-gate 가 ${timeoutSeconds}초 안에 #${prNumber} 의 제목·본문을 채우지 않았습니다. ` + + "그 워크플로 실행을 확인하세요. 지금 live 상태로 판정합니다." + + (lastError ? ` 마지막 조회 오류: ${lastError.message}` : ""), + ); + } else if (polls > 1) { + console.log(`#${prNumber} 게이트가 채운 제목·본문을 ${polls}번째 조회에서 확인`); + } +} + if (import.meta.url === `file://${process.argv[1]}`) { - main().catch((error) => { + const [command, ...rest] = process.argv.slice(2); + const run = + command === undefined + ? fillGate + : command === "await" + ? () => awaitMain(rest[0]) + : () => Promise.reject(new Error(`알 수 없는 명령: ${command}. 인자 없이(채우기) 또는 await 만 받습니다`)); + run().catch((error) => { console.error(`::error::${error.message}`); process.exitCode = 1; }); diff --git a/.github/scripts/dependabot-pr-gate.test.mjs b/.github/scripts/dependabot-pr-gate.test.mjs index 1a1aa9c31..aa6370ea4 100644 --- a/.github/scripts/dependabot-pr-gate.test.mjs +++ b/.github/scripts/dependabot-pr-gate.test.mjs @@ -1,6 +1,12 @@ import assert from "node:assert/strict"; -import { readFile } from "node:fs/promises"; +import { execFile } from "node:child_process"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { createServer } from "node:http"; +import { tmpdir } from "node:os"; +import path from "node:path"; import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; import { inspectCiTestPlan } from "./ci-test-plan.mjs"; import { @@ -9,11 +15,16 @@ import { ISSUE_ASSIGNEES, ISSUE_LABELS, applyGate, + awaitGate, + awaitsGate, buildIssue, buildPlan, needsGate, } from "./dependabot-pr-gate.mjs"; +const GATE_SCRIPT = fileURLToPath(new URL("./dependabot-pr-gate.mjs", import.meta.url)); +const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const BOT_TITLE = "chore(deps): bump the github-actions-updates group with 3 updates"; const BOT_BODY = "Bumps the github-actions-updates group with 3 updates: actions/setup-java ..."; @@ -100,5 +111,258 @@ test("워크플로는 봇 PR 에만 돌고 PR 코드를 체크아웃하지 않 assert.match(source, /^permissions: \{\}/m); assert.match(source, /issues: write/); assert.match(source, /pull-requests: write/); - assert.match(source, /run: node \.github\/scripts\/dependabot-pr-gate\.mjs/); + assert.match(source, /run: node \.github\/scripts\/dependabot-pr-gate\.mjs$/m, "인자 없이 불러야 채우기다"); +}); + +const FILLED = { + user: { login: DEPENDABOT_LOGIN }, + state: "open", + title: `${BOT_TITLE} (#2139)`, + body: `${GATE_MARKER}\nRefs #2139\n\n${BOT_BODY}`, + head: { sha: "a".repeat(40) }, + changed_files: 3, +}; +const UNFILLED = { ...FILLED, title: BOT_TITLE, body: BOT_BODY }; + +function sequence(...pullRequests) { + let calls = 0; + return { + load: async () => pullRequests[Math.min(calls++, pullRequests.length - 1)], + calls: () => calls, + }; +} + +test("await 는 게이트가 채운 뒤의 live PR 을 돌려준다 (#2212)", async () => { + const live = sequence(UNFILLED, UNFILLED, FILLED); + const result = await awaitGate({ loadPullRequest: live.load, timeoutMs: 5_000, intervalMs: 5 }); + + assert.equal(result.timedOut, false); + assert.equal(result.polls, 3); + assert.equal(result.pullRequest, FILLED); + assert.equal(needsGate(result.pullRequest), false); +}); + +test("await 는 기다릴 이유가 없으면 첫 조회로 끝난다", async () => { + const handFilled = { ...UNFILLED, title: `${BOT_TITLE} (#2139)`, body: "Refs #2139\n\n## CI Test Plan\n..." }; + assert.equal(needsGate(handFilled), true, "마커가 없어 게이트는 다시 채우려 한다"); + assert.equal(awaitsGate(handFilled), false, "검증은 사람이 채운 PR 을 기다리지 않는다"); + for (const pullRequest of [ + FILLED, + handFilled, + { ...UNFILLED, user: { login: "1hyok" } }, + { ...UNFILLED, state: "closed" }, + ]) { + const live = sequence(pullRequest); + const result = await awaitGate({ loadPullRequest: live.load, timeoutMs: 5_000, intervalMs: 5 }); + assert.equal(live.calls(), 1, JSON.stringify(pullRequest.user)); + assert.equal(result.timedOut, false); + assert.equal(result.pullRequest, pullRequest); + } +}); + +test("await 는 페이로드가 이미 판정할 수 있으면 API 를 부르지 않는다", async () => { + const live = sequence(UNFILLED); + const ready = await awaitGate({ loadPullRequest: live.load, initial: FILLED, timeoutMs: 5_000, intervalMs: 5 }); + assert.equal(live.calls(), 0, "게이트가 채운 뒤의 edited·synchronize 는 종전처럼 페이로드로 끝난다"); + assert.equal(ready.pullRequest, FILLED); + assert.equal(ready.polls, 0); + + const stale = sequence(UNFILLED, FILLED); + const waited = await awaitGate({ loadPullRequest: stale.load, initial: UNFILLED, timeoutMs: 5_000, intervalMs: 5 }); + assert.equal(stale.calls(), 2, "opened 페이로드는 옛 값이라 live 를 기다린다"); + assert.equal(waited.pullRequest, FILLED); +}); + +test("await 는 조회 오류를 제한 시간 안에서 다시 시도하고, 한 번도 못 읽으면 오류를 던진다", async () => { + let calls = 0; + const flaky = async () => { + calls += 1; + if (calls === 1) throw new Error("GET pulls -> 502"); + return calls === 2 ? UNFILLED : FILLED; + }; + const recovered = await awaitGate({ loadPullRequest: flaky, timeoutMs: 5_000, intervalMs: 5 }); + assert.equal(recovered.timedOut, false); + assert.equal(recovered.pullRequest, FILLED); + assert.equal(calls, 3); + + const down = async () => { + throw new Error("GET pulls -> 403 rate limit"); + }; + await assert.rejects(awaitGate({ loadPullRequest: down, timeoutMs: 30, intervalMs: 5 }), /403 rate limit/); + + let readOnce = 0; + const thenDown = async () => { + readOnce += 1; + if (readOnce === 1) return UNFILLED; + throw new Error("GET pulls -> 502"); + }; + const partial = await awaitGate({ loadPullRequest: thenDown, timeoutMs: 30, intervalMs: 5 }); + assert.equal(partial.timedOut, true); + assert.equal(partial.pullRequest, UNFILLED, "읽은 적이 있으면 마지막 live 상태를 넘긴다"); + assert.match(partial.lastError.message, /502/); +}); + +test("await 는 제한 시간이 지나면 기다림을 끝내고 마지막 live 상태를 넘긴다", async () => { + const live = sequence(UNFILLED); + const started = Date.now(); + const result = await awaitGate({ loadPullRequest: live.load, timeoutMs: 60, intervalMs: 10 }); + + assert.equal(result.timedOut, true); + assert.equal(result.pullRequest, UNFILLED); + assert.ok(live.calls() >= 2, "제한 시간 안에서는 다시 조회한다"); + assert.ok(Date.now() - started < 1_000, "무기한 대기하지 않는다"); + + const once = sequence(UNFILLED); + const immediate = await awaitGate({ loadPullRequest: once.load, timeoutMs: 0, intervalMs: 10 }); + assert.equal(immediate.timedOut, true); + assert.equal(once.calls(), 1); +}); + +async function runGateCli(args, { env = {}, pullRequest } = {}) { + const requests = []; + const server = createServer((request, response) => { + requests.push(`${request.method} ${request.url}`); + response.setHeader("Content-Type", "application/json"); + response.end(JSON.stringify(pullRequest ?? {})); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const { port } = server.address(); + try { + const result = await promisify(execFile)(process.execPath, [GATE_SCRIPT, ...args], { + env: { + PATH: process.env.PATH, + GITHUB_API_URL: `http://127.0.0.1:${port}`, + GITHUB_REPOSITORY: "Afternote/Afternote-FE", + ...env, + }, + }).then( + ({ stdout, stderr }) => ({ code: 0, stdout, stderr }), + (error) => ({ code: error.code, stdout: error.stdout, stderr: error.stderr }), + ); + return { ...result, requests }; + } finally { + server.close(); + } +} + +test("await 명령은 live PR JSON 을 파일에 쓰고, 제한 시간을 넘겨도 판정을 검증에 넘긴다", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "dependabot-pr-gate-")); + try { + const filledPath = path.join(directory, "filled.json"); + const filled = await runGateCli(["await", filledPath], { + env: { GH_TOKEN: "t", PR_NUMBER: "2138", AWAIT_TIMEOUT_SECONDS: "90" }, + pullRequest: FILLED, + }); + assert.equal(filled.code, 0, filled.stderr); + assert.deepEqual(filled.requests, ["GET /repos/Afternote/Afternote-FE/pulls/2138"]); + assert.deepEqual(JSON.parse(await readFile(filledPath, "utf8")), FILLED); + + const unfilledPath = path.join(directory, "unfilled.json"); + const unfilled = await runGateCli(["await", unfilledPath], { + env: { GH_TOKEN: "t", PR_NUMBER: "2138", AWAIT_TIMEOUT_SECONDS: "0" }, + pullRequest: UNFILLED, + }); + assert.equal(unfilled.code, 0, "시간 초과는 실패가 아니다. 검증이 평소 오류 문구로 판정한다"); + assert.match(unfilled.stdout, /::warning::dependabot-pr-gate 가 0초 안에 #2138/); + assert.deepEqual(JSON.parse(await readFile(unfilledPath, "utf8")), UNFILLED); + + const eventPath = path.join(directory, "event.json"); + await writeFile(eventPath, JSON.stringify({ action: "synchronize", pull_request: { ...FILLED, number: 2138 } })); + const payloadPath = path.join(directory, "payload.json"); + const fromPayload = await runGateCli(["await", payloadPath], { + env: { GH_TOKEN: "t", PR_NUMBER: "2138", AWAIT_TIMEOUT_SECONDS: "90", GITHUB_EVENT_PATH: eventPath }, + pullRequest: UNFILLED, + }); + assert.equal(fromPayload.code, 0, fromPayload.stderr); + assert.deepEqual(fromPayload.requests, [], "채워진 페이로드면 API 를 부르지 않는다"); + assert.deepEqual(JSON.parse(await readFile(payloadPath, "utf8")), { ...FILLED, number: 2138 }); + + const otherPath = path.join(directory, "other.json"); + const otherPullRequest = await runGateCli(["await", otherPath], { + env: { GH_TOKEN: "t", PR_NUMBER: "2140", AWAIT_TIMEOUT_SECONDS: "0", GITHUB_EVENT_PATH: eventPath }, + pullRequest: FILLED, + }); + assert.equal(otherPullRequest.code, 0, otherPullRequest.stderr); + assert.deepEqual(otherPullRequest.requests, ["GET /repos/Afternote/Afternote-FE/pulls/2140"], "다른 PR 의 페이로드는 쓰지 않는다"); + + const missing = await runGateCli(["await", path.join(directory, "x.json")], { + env: { GH_TOKEN: "t", PR_NUMBER: "2138" }, + }); + assert.equal(missing.code, 1); + assert.match(missing.stderr, /AWAIT_TIMEOUT_SECONDS/); + assert.deepEqual(missing.requests, [], "입력이 틀리면 API 를 부르지 않는다"); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +test("인자 없이 부르면 채우기다: live PR 을 읽고 봇 PR 이 아니면 아무것도 쓰지 않는다", async () => { + const result = await runGateCli([], { + env: { GITHUB_TOKEN: "t", PR_NUMBER: "2138" }, + pullRequest: { user: { login: "1hyok" }, state: "open", title: "x", body: "" }, + }); + assert.equal(result.code, 0, result.stderr); + assert.deepEqual(result.requests, ["GET /repos/Afternote/Afternote-FE/pulls/2138"]); + assert.match(result.stdout, /작성자가 1hyok 이라 건너뜀/); +}); + +test("모르는 명령은 채우기로 떨어지지 않는다", async () => { + const result = await runGateCli(["wait"], { env: { GITHUB_TOKEN: "t", PR_NUMBER: "2138" } }); + assert.equal(result.code, 1); + assert.match(result.stderr, /알 수 없는 명령: wait/); + assert.deepEqual(result.requests, [], "이슈 생성·PR 편집 요청이 나가지 않는다"); +}); + +test("게이트는 검증을 다시 부르지 않고, 두 검증이 dependabot PR 에서 게이트를 기다린다 (#2212)", async () => { + const readWorkflow = (name) => readFile(new URL(`../workflows/${name}`, import.meta.url), "utf8"); + const gate = await readWorkflow("dependabot-pr-gate.yml"); + const repositoryQuality = await readWorkflow("repository-quality.yml"); + const managedDevice = await readWorkflow("android-managed-device.yml"); + const login = escapeRegExp(DEPENDABOT_LOGIN); + + // 같은 sha 의 dispatch 는 PR 롤업의 옛 실패를 못 덮고(#1825), 재실행은 옛 페이로드를 쓴다. + assert.doesNotMatch(gate, /^\s+actions:/m, "게이트에 actions 권한을 주지 않는다"); + + // Repository Quality: pull_request 이면서 봇이 연 PR 만 live PR 을 기다렸다가 그 값으로 판정한다. + assert.match(repositoryQuality, /PR_AUTHOR: \$\{\{ github\.event\.pull_request\.user\.login \}\}/); + assert.match( + repositoryQuality, + new RegExp( + `elif \\[ "\\$GITHUB_EVENT_NAME" = "pull_request" \\] && \\[ "\\$PR_AUTHOR" = "${login}" \\]; then\\n` + + `(?:\\s+#.*\\n)*` + + `\\s+AWAIT_TIMEOUT_SECONDS=(\\d+) node \\.github/scripts/dependabot-pr-gate\\.mjs await "\\$pull_request_file"\\n` + + `\\s+CHANGED_FILES=\\$\\(jq -r '\\.changed_files' "\\$pull_request_file"\\)`, + ), + ); + const awaitSeconds = Number(repositoryQuality.match(/AWAIT_TIMEOUT_SECONDS=(\d+) node/)[1]); + const quotaSeconds = Number(repositoryQuality.match(/ensure-api-quota\.mjs ensure --max-wait (\d+)/)[1]); + const jobMinutes = Number(repositoryQuality.match(/name: Repository Quality\n\s+runs-on: .+\n\s+timeout-minutes: (\d+)/)[1]); + assert.ok( + awaitSeconds + quotaSeconds + 30 <= jobMinutes * 60, + `게이트 대기 ${awaitSeconds}s + quota 대기 ${quotaSeconds}s 가 job timeout ${jobMinutes}분을 넘기면 판정 전에 취소된다`, + ); + + // Managed Device: 판정 재료는 신뢰 정책 사본으로 기다린 live PR 이다. PR 사본 스크립트를 부르지 않는다. + assert.match( + managedDevice, + /install -m 0644 \\\n\s+\.github\/scripts\/dependabot-pr-gate\.mjs \\\n\s+"\$policy_dir\/dependabot-pr-gate\.mjs"\n\s+dependabot_gate=trusted/, + ); + // 신뢰 사본에 대기 명령이 있는지를 이 문자열로 가른다. 스크립트가 선언 모양을 바꾸면 기기 레인 + // 대기가 조용히 꺼지므로, grep 문자열이 스크립트에 실제로 있는지 대조한다. + const probe = managedDevice.match(/grep -q '([^']+)' \.github\/scripts\/dependabot-pr-gate\.mjs/)?.[1]; + assert.ok(probe, "스테이징 판별 grep 이 없다"); + assert.ok((await readFile(GATE_SCRIPT, "utf8")).includes(probe), `dependabot-pr-gate.mjs 에 '${probe}' 가 없다`); + assert.equal((managedDevice.match(/dependabot_gate=unavailable/g) ?? []).length, 2, "도입 PR·bootstrap 은 페이로드를 읽는다"); + assert.match(managedDevice, /EVENT_PR_AUTHOR: \$\{\{ github\.event\.pull_request\.user\.login \}\}/); + assert.match(managedDevice, /DEPENDABOT_GATE: \$\{\{ steps\.policy\.outputs\.dependabot_gate \}\}/); + assert.match( + managedDevice, + new RegExp(`if \\[\\[ "\\$EVENT_PR_AUTHOR" == "${login}" && "\\$DEPENDABOT_GATE" == "trusted" \\]\\]; then`), + ); + assert.match( + managedDevice, + /AWAIT_TIMEOUT_SECONDS=\d+ \\\n\s+node "\$RUNNER_TEMP\/android-test-policy\/dependabot-pr-gate\.mjs" await "\$pull_request_file"/, + ); + assert.match(managedDevice, /if \[\[ "\$live_head_sha" != "\$EVENT_PR_SHA" \]\]; then/); + assert.doesNotMatch(managedDevice, /node \.github\/scripts\/dependabot-pr-gate\.mjs/); }); diff --git a/.github/workflows/android-managed-device.yml b/.github/workflows/android-managed-device.yml index 7646189a3..aeea5b05c 100644 --- a/.github/workflows/android-managed-device.yml +++ b/.github/workflows/android-managed-device.yml @@ -298,8 +298,21 @@ jobs: # tested revision의 권한 없는 classifier를 신뢰 경계 밖에서 실행하지 않는다. classifier=unavailable fi - printf 'renderer=%s\nclassifier=%s\nexpected_failures=%s\nsource=trusted\n' \ - "$renderer" "$classifier" "$expected_failures" >> "$GITHUB_OUTPUT" + # dependabot PR 은 게이트가 CI Test Plan 을 채울 때까지 이 신뢰 사본으로 기다린다(#2212). + # 파일 존재가 아니라 await 명령의 존재로 가른다. 이 파일은 #2112 부터 default branch 에 + # 있어서, 존재만 보면 도입 PR 이 명령 없는 옛 사본을 부른다. + if [[ -f .github/scripts/dependabot-pr-gate.mjs ]] && + grep -q 'export async function awaitGate' .github/scripts/dependabot-pr-gate.mjs; then + install -m 0644 \ + .github/scripts/dependabot-pr-gate.mjs \ + "$policy_dir/dependabot-pr-gate.mjs" + dependabot_gate=trusted + else + # 도입 PR 은 사람이 연다. 기다릴 게이트가 없으니 종전처럼 이벤트 페이로드를 읽는다. + dependabot_gate=unavailable + fi + printf 'renderer=%s\nclassifier=%s\nexpected_failures=%s\ndependabot_gate=%s\nsource=trusted\n' \ + "$renderer" "$classifier" "$expected_failures" "$dependabot_gate" >> "$GITHUB_OUTPUT" elif [[ ! -e .github/scripts/ci-test-plan.mjs && ! -e .github/scripts/resolve-android-test-plan.mjs && ! -e .github/scripts/resolve-pr-impact.mjs && @@ -312,6 +325,7 @@ jobs: renderer=bootstrap \ classifier=unavailable \ expected_failures=unavailable \ + dependabot_gate=unavailable \ source=bootstrap >> "$GITHUB_OUTPUT" else echo "::error::Android test policy가 부분 설치된 상태입니다" @@ -322,10 +336,12 @@ jobs: id: target env: DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + DEPENDABOT_GATE: ${{ steps.policy.outputs.dependabot_gate }} DISPATCH_REF_NAME: ${{ github.ref_name }} DISPATCH_PR_NUMBER: ${{ inputs.pull_request_number || 0 }} DEVICE: ${{ matrix.device }} EVENT_NAME: ${{ github.event_name }} + EVENT_PR_AUTHOR: ${{ github.event.pull_request.user.login }} EVENT_PR_BRANCH: ${{ github.event.pull_request.head.ref }} EVENT_PR_NUMBER: ${{ github.event.pull_request.number }} EVENT_PR_SHA: ${{ github.event.pull_request.head.sha }} @@ -344,7 +360,22 @@ jobs: target_pr="$EVENT_PR_NUMBER" target_branch="$EVENT_PR_BRANCH" target_sha="$EVENT_PR_SHA" - jq '.pull_request' "$GITHUB_EVENT_PATH" > "$pull_request_file" + if [[ "$EVENT_PR_AUTHOR" == "dependabot[bot]" && "$DEPENDABOT_GATE" == "trusted" ]]; then + # dependabot-pr-gate 가 같은 순간 GITHUB_TOKEN 으로 CI Test Plan 을 채운다. 그 edited 는 + # 이 워크플로를 다시 깨우지 못하고 페이로드는 opened 시점 값이다(#2212). 페이로드 제목에 + # 대표 이슈 번호가 이미 있으면 API 없이 그대로 쓰고, 없으면 게이트가 채울 때까지 live PR 을 + # 기다린다. 재실행으로 풀면 실제 테스트가 attempt 2 에서 돌아 1회차 인프라 복구를 잃는다. + # lane timeout 이 15분 이상이라 3분까지 기다린다. + PR_NUMBER="$EVENT_PR_NUMBER" AWAIT_TIMEOUT_SECONDS=180 \ + node "$RUNNER_TEMP/android-test-policy/dependabot-pr-gate.mjs" await "$pull_request_file" + live_head_sha="$(jq -r '.head.sha' "$pull_request_file")" + if [[ "$live_head_sha" != "$EVENT_PR_SHA" ]]; then + echo "::error::기다리는 사이 PR head 가 $EVENT_PR_SHA 에서 $live_head_sha 로 움직였습니다. 새 head 의 run 이 판정합니다" + exit 1 + fi + else + jq '.pull_request' "$GITHUB_EVENT_PATH" > "$pull_request_file" + fi elif [[ "$EVENT_NAME" == "workflow_dispatch" && "$DISPATCH_PR_NUMBER" != "0" ]]; then if ! [[ "$DISPATCH_PR_NUMBER" =~ ^[1-9][0-9]*$ ]]; then echo "::error::유효한 pull_request_number 가 필요합니다" diff --git a/.github/workflows/dependabot-pr-gate.yml b/.github/workflows/dependabot-pr-gate.yml index cd5e362f7..9e29ba67f 100644 --- a/.github/workflows/dependabot-pr-gate.yml +++ b/.github/workflows/dependabot-pr-gate.yml @@ -6,6 +6,11 @@ # pull_request_target 인 이유: dependabot 이 연 pull_request 이벤트의 토큰은 읽기 전용이라 # 이슈 생성·PR 편집을 못 한다. default branch 의 워크플로 정의만 실행되고, PR 코드는 # 체크아웃하지도 실행하지도 않는다. default branch 의 정책 스크립트와 GitHub API 응답만 쓴다. +# +# 이 편집이 낸 edited 는 GITHUB_TOKEN 이라 PR Validation·Managed Device 를 다시 깨우지 못한다. +# 여기서 그 둘을 dispatch·재실행하지 않고 actions 권한도 두지 않는다. 같은 sha 의 dispatch 초록은 +# PR 롤업이 옛 실패를 덮지 못하고(#1825), 재실행은 옛 페이로드를 쓴다. 대신 두 검증이 dependabot +# PR 에서만 이 게이트가 채울 때까지 live PR 을 기다린다(#2212, dependabot-pr-gate.mjs await). name: dependabot-pr-gate on: diff --git a/.github/workflows/repository-quality.yml b/.github/workflows/repository-quality.yml index 2c173a018..e333f8267 100644 --- a/.github/workflows/repository-quality.yml +++ b/.github/workflows/repository-quality.yml @@ -118,6 +118,7 @@ jobs: CHANGED_FILES: ${{ github.event.pull_request.changed_files }} GH_TOKEN: ${{ github.token }} MAX_PATH_FILTER_FILES: 300 + PR_AUTHOR: ${{ github.event.pull_request.user.login }} PR_NUMBER: ${{ inputs.pull_request_number }} run: | set -euo pipefail @@ -138,6 +139,14 @@ jobs: fi CHANGED_FILES=$(jq -r '.changed_files' <<< "$pull_request") printf '%s' "$pull_request" > "$pull_request_file" + elif [ "$GITHUB_EVENT_NAME" = "pull_request" ] && [ "$PR_AUTHOR" = "dependabot[bot]" ]; then + # dependabot-pr-gate 가 같은 순간 GITHUB_TOKEN 으로 제목 (#N)·Refs·CI Test Plan 을 채운다. + # 그 edited 는 이 워크플로를 다시 깨우지 못하고 페이로드는 opened 시점 값이다(#2212). + # 페이로드 제목에 대표 이슈 번호가 이미 있으면 API 없이 그대로 쓰고, 없으면 게이트가 채울 + # 때까지 live PR 을 기다렸다가 그 값으로 판정한다. quota 대기 60초와 합쳐도 job timeout + # 3분 안에 들도록 90초에서 끊는다. + AWAIT_TIMEOUT_SECONDS=90 node .github/scripts/dependabot-pr-gate.mjs await "$pull_request_file" + CHANGED_FILES=$(jq -r '.changed_files' "$pull_request_file") elif [ -z "$CHANGED_FILES" ]; then pull_request=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER") CHANGED_FILES=$(jq -r '.changed_files' <<< "$pull_request")