From 135b8b66b66d8c06fb9acef322c568b5bd970ecf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Tue, 25 Aug 2026 23:58:44 +0900 Subject: [PATCH] =?UTF-8?q?ci(review-debt-guard):=20=ED=8C=80=EC=9B=90=20?= =?UTF-8?q?=ED=8C=90=EC=A0=95=EC=9D=84=20association=20=EC=95=84=EB=8B=8C?= =?UTF-8?q?=20=EC=8B=A4=EA=B6=8C=ED=95=9C=EC=9C=BC=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 웹훅 페이로드의 author_association 은 조직 멤버십이 비공개면 MEMBER 대신 CONTRIBUTOR 를 내린다. 이 조직은 공개 멤버가 0명이라, 레포에 직접 초대된 한 계정만 COLLABORATOR 로 찍혀 검사를 받고 나머지 팀원은 전원 «외부 기여자» 로 건너뛰어졌다. 가드가 한 방향으로만 돌고 있었다. 8/25 실측 — 23:30·23:35 에 열린 #1152·#1154 는 «외부 기여자(CONTRIBUTOR) — 건너뜀» 한 줄로 통과했고, 12분 뒤 그 둘을 포함한 빚 3건을 두고 #1112 만 닫혔다. REST 로 조회하면 두 계정 다 MEMBER 로 보여 눈으로는 잡히지 않는다. collaborators/{user}/permission 으로 실권한을 묻는다. fork 는 토큰이 read-only 라 닫지도 못하는 것이 원래 이유였으므로 head 저장소로 따로 가른다 — association 은 그 판정에 쓸 수 있는 축이 아니었다. 조회 실패와 예상 밖 권한 값은 통과시키지 않고 run 을 세운다. 빈 값을 «권한 없음» 으로 접으면 조회 실패가 통과로 둔갑한다. --- .github/scripts/review-debt-policy.test.mjs | 53 +++++++++++++++++++++ .github/workflows/review-debt-guard.yml | 30 +++++++++--- 2 files changed, 77 insertions(+), 6 deletions(-) create mode 100644 .github/scripts/review-debt-policy.test.mjs diff --git a/.github/scripts/review-debt-policy.test.mjs b/.github/scripts/review-debt-policy.test.mjs new file mode 100644 index 0000000000..dd4583f01c --- /dev/null +++ b/.github/scripts/review-debt-policy.test.mjs @@ -0,0 +1,53 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +const guard = await readFile(new URL("../workflows/review-debt-guard.yml", import.meta.url), "utf8"); + +test("team membership is judged by real permission, not author_association", () => { + // 웹훅 페이로드의 author_association 은 조직 멤버십이 비공개면 MEMBER 대신 + // CONTRIBUTOR 를 내린다. 이 조직은 공개 멤버가 0명이라, 레포에 직접 초대된 한 + // 계정만 COLLABORATOR 로 찍혀 검사를 받고 나머지 팀원은 전원 건너뛰어졌다. + // 가드가 한 사람만 막는 상태였다 (8/25 #1152·#1154 통과 / #1112 만 닫힘). + // 주석은 이력이라 남기고, 실제로 값을 끌어오는 자리만 금지한다. + assert.doesNotMatch(guard, /^\s*[A-Z_]+:\s*\$\{\{[^\n]*author_association/m); + assert.doesNotMatch(guard, /\$ASSOCIATION/); + assert.match(guard, /collaborators\/\$AUTHOR\/permission/); +}); + +test("only write-capable accounts are subject to the guard", () => { + // read 권한만 있는 계정은 리뷰로 판정을 낼 수 없으니 빚을 질 수도 없다. + assert.match(guard, /^\s*admin\|maintain\|write\)/m); + // 빈 값·예상 밖 값까지 «권한 없음» 으로 접으면 조회 실패가 통과로 둔갑한다. + assert.match(guard, /^\s*read\|none\)[^\n]*exit 0/m); + assert.match(guard, /^\s*\*\) echo "::error::권한 값을 알 수 없다[^\n]*exit 1/m); +}); + +test("a failed permission lookup fails the run instead of passing the pull request", () => { + // 조회 실패를 «권한 없음» 으로 접으면 가드 전체가 조용히 무력해진다. + assert.match(guard, /if ! perm=\$\(gh api[^\n]*\n\s*echo "::error::[^\n]*\n\s*exit 1/); +}); + +test("forks are excluded by head repository, not by contributor status", () => { + // fork PR 은 토큰이 read-only 라 닫지도 못한다. 그것이 원래 걸러내려던 것이고, + // author_association 은 그 판정에 쓸 수 없는 축이었다. + assert.match(guard, /\[ "\$HEAD_REPO" != "\$REPO" \]/); +}); + +test("the debt sweep still fails closed when open pull requests cannot be listed", () => { + // 조회 실패를 «빚 없음» 으로 오인하면 새 PR 이 전부 통과한다. + assert.match(guard, /if ! open_prs=\$\(gh api[^\n]*\n(?:[^\n]*\n)*?\s*exit 1/); +}); + +test("a pull request is never counted as debt against its own author", () => { + // 자기 PR 은 자신이 리뷰할 수 없다. + assert.match(guard, /select\(\.user\.login != \\"\$AUTHOR\\"\)/); +}); + +test("changes requested becomes debt again once the author has pushed a fix", () => { + // 변경요청은 끝난 판정이 아니다. 반영 뒤 재리뷰가 없으면 그 PR 은 영영 머지되지 + // 못한다. 병합 커밋은 반영이 아니므로 parents 2개 이상은 빼고 센다. + assert.match(guard, /CHANGES_REQUESTED/); + assert.match(guard, /\(\.parents \| length\) < 2/); + assert.match(guard, /select\(\.commit\.committer\.date > \\"\$blocked_at\\"\)/); +}); diff --git a/.github/workflows/review-debt-guard.yml b/.github/workflows/review-debt-guard.yml index 3df105ced0..5da62a3504 100644 --- a/.github/workflows/review-debt-guard.yml +++ b/.github/workflows/review-debt-guard.yml @@ -28,7 +28,7 @@ jobs: REPO: ${{ github.repository }} PR_NUMBER: ${{ github.event.pull_request.number }} AUTHOR: ${{ github.event.pull_request.user.login }} - ASSOCIATION: ${{ github.event.pull_request.author_association }} + HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }} run: | # 조회 실패를 "빚 없음" 으로 오인해 통과시키지 않는다. 다만 판정 근거를 @@ -40,11 +40,29 @@ jobs: *'[bot]'|dependabot|github-actions) echo "봇 PR — 건너뜀"; exit 0 ;; esac - # 외부 기여자는 대상이 아니다. fork PR 은 토큰이 read-only 라 - # 닫지도 못하고, 팀 리뷰 적체와도 무관하다. - case "$ASSOCIATION" in - OWNER|MEMBER|COLLABORATOR) ;; - *) echo "외부 기여자($ASSOCIATION) — 건너뜀"; exit 0 ;; + # fork PR 은 대상이 아니다 — 토큰이 read-only 라 닫지도 못하고, 팀 리뷰 + # 적체와도 무관하다. 원래 걸러내려던 것이 이것이므로 head 저장소로 가른다. + if [ "$HEAD_REPO" != "$REPO" ]; then + echo "fork PR($HEAD_REPO) — 건너뜀" + exit 0 + fi + + # 팀원 여부는 author_association 으로 가리지 않는다. 웹훅 페이로드는 조직 + # 멤버십이 비공개면 MEMBER 대신 CONTRIBUTOR 를 내리는데 이 조직은 공개 + # 멤버가 0명이다. 그래서 레포에 직접 초대된 한 계정만 COLLABORATOR 로 찍혀 + # 검사를 받고, 나머지 팀원은 전원 «외부 기여자» 로 건너뛰어졌다 — 8/25 에 + # #1152·#1154 가 그렇게 통과했고 12분 뒤 같은 빚으로 #1112 만 닫혔다. + # REST 로 조회하면 MEMBER 로 보여 눈으로는 잡히지 않는다. 실권한을 묻는다. + if ! perm=$(gh api "repos/$REPO/collaborators/$AUTHOR/permission" --jq '.permission'); then + echo "::error::권한 조회에 실패했다 — 근거 없이 통과시키지 않는다. 재실행할 것" + exit 1 + fi + + # 빈 값·예상 밖 값을 «권한 없음» 으로 접으면 조회 실패가 통과로 둔갑한다. + case "$perm" in + admin|maintain|write) ;; + read|none) echo "쓰기 권한 없음($perm) — 건너뜀"; exit 0 ;; + *) echo "::error::권한 값을 알 수 없다($perm) — 재실행할 것"; exit 1 ;; esac # 긴급 우회 라벨.