From 29798d183e45260543ed4742475e2eb2e276a35b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Mon, 28 Sep 2026 18:31:43 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix(platform):=20dependabot=20PR=20?= =?UTF-8?q?=EC=9D=98=20=EB=91=90=20=EA=B2=80=EC=A6=9D=EC=9D=B4=20=EA=B2=8C?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=EA=B0=80=20=EC=B1=84=EC=9A=B4=20=EC=A0=9C?= =?UTF-8?q?=EB=AA=A9=C2=B7=EB=B3=B8=EB=AC=B8=EC=9D=84=20=EA=B8=B0=EB=8B=A4?= =?UTF-8?q?=EB=A0=B8=EB=8B=A4=EA=B0=80=20=ED=8C=90=EC=A0=95=ED=95=9C?= =?UTF-8?q?=EB=8B=A4=20(#2212)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 게이트는 GITHUB_TOKEN 으로 편집해 edited 가 다른 워크플로를 깨우지 못하고, PR Validation·Managed Device 는 opened 시점 페이로드를 읽어 red 로 남았다. dependabot 이 연 PR 의 pull_request run 에서만 두 검증이 live PR 을 needsGate 가 false 가 될 때까지 기다린 뒤 그 값으로 판정한다. 기기 레인은 default branch 신뢰 사본으로 기다린다. 게이트 권한은 늘리지 않는다. --- .github/scripts/dependabot-pr-gate.mjs | 82 +++++++- .github/scripts/dependabot-pr-gate.test.mjs | 187 ++++++++++++++++++- .github/workflows/android-managed-device.yml | 36 +++- .github/workflows/dependabot-pr-gate.yml | 5 + .github/workflows/repository-quality.yml | 8 + 5 files changed, 310 insertions(+), 8 deletions(-) diff --git a/.github/scripts/dependabot-pr-gate.mjs b/.github/scripts/dependabot-pr-gate.mjs index 58b0e7a7b0..e520bf81a5 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·buildPlan·buildIssue·applyGate)·대기(awaitGate)와 GitHub 호출(main)을 +// 갈라 두어 정책은 dependabot-pr-gate.test.mjs 가 잠근다. + +import { writeFile } from "node:fs/promises"; import { inspectAndroidTestImpact } from "./ci-test-plan.mjs"; @@ -53,6 +62,32 @@ export function buildPlan(changedPaths) { }; } +/** `await` 가 live PR 을 다시 조회하는 간격. 게이트는 job 시작 뒤 수 초 안에 끝난다(#2138 실측 7초). */ +export const AWAIT_POLL_INTERVAL_MS = 5_000; + +/** + * 게이트가 제목·본문을 채울 때까지 live PR 을 다시 읽는다(#2212). dependabot 이 연 열린 PR 이 + * 아니거나 이미 채워졌으면 첫 조회 결과를 바로 돌려준다. 제한 시간이 지나면 기다림을 끝내고 마지막 + * live 상태를 돌려준다. 판정은 호출한 검증의 몫이라, 게이트가 실패했으면 검증이 평소 오류 문구로 + * 실패한다. + */ +export async function awaitGate({ loadPullRequest, timeoutMs, intervalMs = AWAIT_POLL_INTERVAL_MS }) { + const deadline = Date.now() + timeoutMs; + for (let polls = 1; ; polls += 1) { + const pullRequest = await loadPullRequest(); + const waiting = + pullRequest?.user?.login === DEPENDABOT_LOGIN && pullRequest?.state === "open" && needsGate(pullRequest); + if (!waiting) { + return { pullRequest, polls, timedOut: false }; + } + const remaining = deadline - Date.now(); + if (remaining <= 0) { + return { pullRequest, polls, timedOut: true }; + } + 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 +188,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 +235,47 @@ async function main() { console.log(`#${prNumber} 제목·본문 갱신: Refs #${issueNumber}, androidTest mode=${plan.mode}`); } +/** + * `await <출력 경로>`: dependabot PR 의 `pull_request` 검증이 부른다. 게이트를 기다린 뒤 live PR JSON 을 + * 출력 경로에 쓴다. 제한 시간을 넘겨도 실패하지 않고 경고만 남긴다. 판정은 뒤따르는 검증이 한다. + */ +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 { pullRequest, polls, timedOut } = await awaitGate({ + loadPullRequest: () => github(`${apiUrl}/repos/${repository}/pulls/${prNumber}`, token), + timeoutMs: timeoutSeconds * 1000, + }); + await writeFile(outputPath, JSON.stringify(pullRequest)); + if (timedOut) { + console.log( + `::warning::dependabot-pr-gate 가 ${timeoutSeconds}초 안에 #${prNumber} 의 제목·본문을 채우지 않았습니다. ` + + "그 워크플로 실행을 확인하세요. 지금 live 상태로 판정합니다.", + ); + } 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 1a1aa9c31b..3f75adcd1c 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 } 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,15 @@ import { ISSUE_ASSIGNEES, ISSUE_LABELS, applyGate, + awaitGate, 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 ..."; @@ -102,3 +112,178 @@ test("워크플로는 봇 PR 에만 돌고 PR 코드를 체크아웃하지 않 assert.match(source, /pull-requests: write/); assert.match(source, /run: node \.github\/scripts\/dependabot-pr-gate\.mjs/); }); + +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 () => { + for (const pullRequest of [ + FILLED, + { ...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 는 제한 시간이 지나면 기다림을 끝내고 마지막 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 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("모르는 명령은 채우기로 떨어지지 않는다", 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 권한을 주지 않는다"); + assert.doesNotMatch(gate, /dispatches|rerun|workflow run/); + + // 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/, + ); + assert.match(managedDevice, /grep -q 'export async function awaitGate' \.github\/scripts\/dependabot-pr-gate\.mjs/); + 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 7646189a31..651425bdf3 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,21 @@ 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 시점 값이라, 게이트가 채울 때까지 + # live PR 을 기다린다(#2212). 재실행으로 풀면 실제 테스트가 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 cd5e362f72..9e29ba67f8 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 2c173a018d..05c8058e89 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,13 @@ 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 시점 값이라, 게이트가 + # 채울 때까지 live PR 을 기다렸다가 그 값으로 판정한다(#2212). 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") From 4f988ce38cbfae7c7076de00760a6149ef3b70e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Mon, 28 Sep 2026 18:52:56 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix(platform):=20=EC=B1=84=EC=9B=8C?= =?UTF-8?q?=EC=A7=84=20=ED=8E=98=EC=9D=B4=EB=A1=9C=EB=93=9C=EB=8A=94=20API?= =?UTF-8?q?=20=EC=97=86=EC=9D=B4=20=EC=93=B0=EA=B3=A0,=20=EC=82=AC?= =?UTF-8?q?=EB=9E=8C=EC=9D=B4=20=EC=B1=84=EC=9A=B4=20dependabot=20PR=20?= =?UTF-8?q?=EC=9D=80=20=EA=B8=B0=EB=8B=A4=EB=A6=AC=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EB=8A=94=EB=8B=A4=20(#2212)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 리뷰에서 나온 지적을 반영한다. 게이트가 채운 뒤의 edited·synchronize 까지 기기 레인이 매번 API 를 불러 일시 오류 하나에 required lane 이 red 가 될 수 있었다. 이벤트 페이로드 제목에 대표 이슈 번호가 이미 있으면 종전처럼 페이로드를 쓴다. 대기 종료는 게이트 마커가 아니라 제목의 대표 이슈 번호로 가른다. 조회 오류는 제한 시간 안에서 다시 시도한다. 스테이징 판별 grep 문자열이 스크립트에 실제로 있는지, 인자 없는 호출이 채우기인지 테스트로 잠근다. --- .github/scripts/dependabot-pr-gate.mjs | 69 +++++++++++---- .github/scripts/dependabot-pr-gate.test.mjs | 89 ++++++++++++++++++-- .github/workflows/android-managed-device.yml | 7 +- .github/workflows/repository-quality.yml | 7 +- 4 files changed, 143 insertions(+), 29 deletions(-) diff --git a/.github/scripts/dependabot-pr-gate.mjs b/.github/scripts/dependabot-pr-gate.mjs index e520bf81a5..63548b51b2 100644 --- a/.github/scripts/dependabot-pr-gate.mjs +++ b/.github/scripts/dependabot-pr-gate.mjs @@ -13,10 +13,10 @@ // 1회차 인프라 복구를 잃으며, App 토큰은 이 브리지에 금지된 시크릿이 필요하다. 그래서 방향을 // 뒤집었다. 검증이 `await` 명령으로 게이트가 채울 때까지 live PR 을 기다렸다가 그 값으로 판정한다. // -// 순수 함수(needsGate·buildPlan·buildIssue·applyGate)·대기(awaitGate)와 GitHub 호출(main)을 -// 갈라 두어 정책은 dependabot-pr-gate.test.mjs 가 잠근다. +// 순수 함수(needsGate·awaitsGate·buildPlan·buildIssue·applyGate)·대기(awaitGate)와 GitHub 호출 +// (fillGate·awaitMain)을 갈라 두어 정책은 dependabot-pr-gate.test.mjs 가 잠근다. -import { writeFile } from "node:fs/promises"; +import { readFile, writeFile } from "node:fs/promises"; import { inspectAndroidTestImpact } from "./ci-test-plan.mjs"; @@ -66,23 +66,50 @@ export function buildPlan(changedPaths) { export const AWAIT_POLL_INTERVAL_MS = 5_000; /** - * 게이트가 제목·본문을 채울 때까지 live PR 을 다시 읽는다(#2212). dependabot 이 연 열린 PR 이 - * 아니거나 이미 채워졌으면 첫 조회 결과를 바로 돌려준다. 제한 시간이 지나면 기다림을 끝내고 마지막 - * live 상태를 돌려준다. 판정은 호출한 검증의 몫이라, 게이트가 실패했으면 검증이 평소 오류 문구로 - * 실패한다. + * 검증이 아직 기다려야 하는 PR 인가. dependabot 이 연 열린 PR 인데 제목에 대표 이슈 번호가 없을 때만이다. + * 마커가 아니라 제목으로 가른다. 게이트가 경계 경로에서 멈춰 사람이 손으로 채운 PR 에는 마커가 없는데, + * 그 PR 까지 edited 마다 제한 시간을 꽉 채워 기다리게 하지 않는다. 게이트는 제목과 본문을 한 번의 + * PATCH 로 바꾸므로 제목에 번호가 있으면 본문도 채워져 있다. */ -export async function awaitGate({ loadPullRequest, timeoutMs, intervalMs = AWAIT_POLL_INTERVAL_MS }) { +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) { - const pullRequest = await loadPullRequest(); - const waiting = - pullRequest?.user?.login === DEPENDABOT_LOGIN && pullRequest?.state === "open" && needsGate(pullRequest); - if (!waiting) { - return { pullRequest, polls, timedOut: false }; + 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) { - return { pullRequest, polls, timedOut: true }; + if (latest === undefined) { + throw lastError; + } + return { pullRequest: latest, polls, timedOut: true, lastError }; } await new Promise((resolve) => setTimeout(resolve, Math.min(intervalMs, remaining))); } @@ -236,8 +263,9 @@ async function fillGate() { } /** - * `await <출력 경로>`: dependabot PR 의 `pull_request` 검증이 부른다. 게이트를 기다린 뒤 live PR JSON 을 - * 출력 경로에 쓴다. 제한 시간을 넘겨도 실패하지 않고 경고만 남긴다. 판정은 뒤따르는 검증이 한다. + * `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; @@ -252,15 +280,20 @@ async function awaitMain(outputPath) { throw new Error(`AWAIT_TIMEOUT_SECONDS 는 0 이상의 정수여야 합니다: ${process.env.AWAIT_TIMEOUT_SECONDS}`); } - const { pullRequest, polls, timedOut } = await awaitGate({ + 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 상태로 판정합니다.", + "그 워크플로 실행을 확인하세요. 지금 live 상태로 판정합니다." + + (lastError ? ` 마지막 조회 오류: ${lastError.message}` : ""), ); } else if (polls > 1) { console.log(`#${prNumber} 게이트가 채운 제목·본문을 ${polls}번째 조회에서 확인`); diff --git a/.github/scripts/dependabot-pr-gate.test.mjs b/.github/scripts/dependabot-pr-gate.test.mjs index 3f75adcd1c..aa6370ea47 100644 --- a/.github/scripts/dependabot-pr-gate.test.mjs +++ b/.github/scripts/dependabot-pr-gate.test.mjs @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { execFile } from "node:child_process"; -import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { createServer } from "node:http"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -16,6 +16,7 @@ import { ISSUE_LABELS, applyGate, awaitGate, + awaitsGate, buildIssue, buildPlan, needsGate, @@ -110,7 +111,7 @@ 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 = { @@ -142,8 +143,12 @@ test("await 는 게이트가 채운 뒤의 live PR 을 돌려준다 (#2212)", as }); 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" }, ]) { @@ -155,6 +160,48 @@ test("await 는 기다릴 이유가 없으면 첫 조회로 끝난다", async () } }); +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(); @@ -219,6 +266,25 @@ test("await 명령은 live PR JSON 을 파일에 쓰고, 제한 시간을 넘겨 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" }, }); @@ -230,6 +296,16 @@ test("await 명령은 live PR JSON 을 파일에 쓰고, 제한 시간을 넘겨 } }); +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); @@ -246,7 +322,6 @@ test("게이트는 검증을 다시 부르지 않고, 두 검증이 dependabot P // 같은 sha 의 dispatch 는 PR 롤업의 옛 실패를 못 덮고(#1825), 재실행은 옛 페이로드를 쓴다. assert.doesNotMatch(gate, /^\s+actions:/m, "게이트에 actions 권한을 주지 않는다"); - assert.doesNotMatch(gate, /dispatches|rerun|workflow run/); // Repository Quality: pull_request 이면서 봇이 연 PR 만 live PR 을 기다렸다가 그 값으로 판정한다. assert.match(repositoryQuality, /PR_AUTHOR: \$\{\{ github\.event\.pull_request\.user\.login \}\}/); @@ -254,7 +329,7 @@ test("게이트는 검증을 다시 부르지 않고, 두 검증이 dependabot P repositoryQuality, new RegExp( `elif \\[ "\\$GITHUB_EVENT_NAME" = "pull_request" \\] && \\[ "\\$PR_AUTHOR" = "${login}" \\]; then\\n` + - `(?:\\s+#.*\\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"\\)`, ), @@ -272,7 +347,11 @@ test("게이트는 검증을 다시 부르지 않고, 두 검증이 dependabot P 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/, ); - assert.match(managedDevice, /grep -q 'export async function awaitGate' \.github\/scripts\/dependabot-pr-gate\.mjs/); + // 신뢰 사본에 대기 명령이 있는지를 이 문자열로 가른다. 스크립트가 선언 모양을 바꾸면 기기 레인 + // 대기가 조용히 꺼지므로, 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 \}\}/); diff --git a/.github/workflows/android-managed-device.yml b/.github/workflows/android-managed-device.yml index 651425bdf3..aeea5b05c7 100644 --- a/.github/workflows/android-managed-device.yml +++ b/.github/workflows/android-managed-device.yml @@ -362,9 +362,10 @@ jobs: target_sha="$EVENT_PR_SHA" if [[ "$EVENT_PR_AUTHOR" == "dependabot[bot]" && "$DEPENDABOT_GATE" == "trusted" ]]; then # dependabot-pr-gate 가 같은 순간 GITHUB_TOKEN 으로 CI Test Plan 을 채운다. 그 edited 는 - # 이 워크플로를 다시 깨우지 못하고 페이로드는 opened 시점 값이라, 게이트가 채울 때까지 - # live PR 을 기다린다(#2212). 재실행으로 풀면 실제 테스트가 attempt 2 에서 돌아 1회차 - # 인프라 복구를 잃는다. lane timeout 이 15분 이상이라 3분까지 기다린다. + # 이 워크플로를 다시 깨우지 못하고 페이로드는 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")" diff --git a/.github/workflows/repository-quality.yml b/.github/workflows/repository-quality.yml index 05c8058e89..e333f82672 100644 --- a/.github/workflows/repository-quality.yml +++ b/.github/workflows/repository-quality.yml @@ -141,9 +141,10 @@ jobs: 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 시점 값이라, 게이트가 - # 채울 때까지 live PR 을 기다렸다가 그 값으로 판정한다(#2212). quota 대기 60초와 합쳐도 - # job timeout 3분 안에 들도록 90초에서 끊는다. + # 그 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