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
115 changes: 111 additions & 4 deletions .github/scripts/dependabot-pr-gate.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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}`;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
});
Expand Down
Loading
Loading