diff --git a/.github/scripts/merge-order-guard-policy.test.mjs b/.github/scripts/merge-order-guard-policy.test.mjs index 836a5b196..188603b70 100644 --- a/.github/scripts/merge-order-guard-policy.test.mjs +++ b/.github/scripts/merge-order-guard-policy.test.mjs @@ -195,7 +195,7 @@ test("closing a blocker refreshes PRs found only through unlinked close keywords test("refresh queries every open PR page with the same repository-aware closing data", () => { assert.match(guard, /gh api graphql --paginate --slurp/); assert.match(guard, /pullRequests\(states:OPEN,first:100,after:\$endCursor\)/); - assert.match(guard, /nodes\{number title body closingIssuesReferences/); + assert.match(guard, /nodes\{number headRefOid title body closingIssuesReferences/); assert.match(guard, /pageInfo\{hasNextPage endCursor\}/); }); @@ -380,11 +380,20 @@ test("merge queue still fails on an open issue blocker", () => { assert.equal(result.status, 1, result.stderr); }); -test("blocker changes dispatch current default-branch policy instead of rerunning old workflow code", () => { +test("blocker changes rerun the trusted pull_request_target run and dispatch only when none exists (#1983)", () => { + // #1955 는 rerun 이 PR 사본의 옛 워크플로 코드를 돌린다는 이유로 dispatch 재게시로 바꿨다. + // #1977 뒤 PR HEAD 의 run 은 base YAML·default-branch 스크립트로 도는 run 이라 재실행이 곧 + // 최신 판정이고, check-run 재게시가 임의 suite 에 얹혀 무관한 워크플로 이름 아래 guard 가 + // 또 뜨는 일을 피한다. rerun 대상은 pull_request_target run 으로만 좁힌다. + assert.match(guard, /gh run list --workflow merge-order-guard.yml --event pull_request_target \\\n\s+--commit "\$sha" --status completed --limit 1/); + assert.match(guard, /\[ -n "\$run_id" \] && gh run rerun "\$run_id"/); + assert.doesNotMatch(guard, /gh run list --workflow merge-order-guard.yml --branch/); assert.match(guard, /gh workflow run merge-order-guard.yml --ref "\$DEFAULT_BRANCH" -f pull_request_number="\$pr"/); - assert.doesNotMatch(guard, /gh run rerun/); assert.match(guard, /github.ref_name == github.event.repository.default_branch/); assert.match(guard, /needs.guard.outputs.target_sha/); + // HEAD SHA 는 이미 받은 GraphQL 페이지에서 읽는다 — PR 마다 REST 왕복을 더하지 않는다 (#1465). + assert.match(guard, /nodes\{number headRefOid title body closingIssuesReferences/); + assert.doesNotMatch(guard.slice(guard.indexOf("dispatch_failed=0")), /gh pr view/); }); test("dispatch publication refuses changed or closed HEAD and publishes failure on the captured HEAD", () => { @@ -420,20 +429,62 @@ test("dispatch publication refuses changed or closed HEAD and publishes failure }); -test("one failed refresh dispatch does not prevent the remaining PRs from refreshing", () => { +function runRefreshLoop(ghMock, prs) { const start = guard.indexOf(" dispatch_failed=0"); assert.ok(start >= 0); const script = guard.slice(start).split("\n").map((line) => line.slice(10)).join("\n"); - const result = spawnSync("bash", ["-c", `set -euo pipefail -heads="101 102" + const directory = mkdtempSync(join(tmpdir(), "merge-order-refresh-loop-")); + const prsFile = join(directory, "open-prs.json"); + writeFileSync(prsFile, JSON.stringify([{ data: { repository: { pullRequests: { nodes: prs } } } }])); + try { + return spawnSync("bash", ["-c", `set -euo pipefail +heads="${prs.map((pr) => pr.number).join(" ")}" DEFAULT_BRANCH=develop -gh() { echo "$*"; [[ "$*" != *pull_request_number=101* ]]; } +prs_file="${prsFile}" +${ghMock} ` + script], { encoding: "utf8" }); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +} + +test("one failed refresh dispatch does not prevent the remaining PRs from refreshing", () => { + const result = runRefreshLoop(`gh() { + [ "$1 $2" != "run list" ] || return 0 + echo "$*"; [[ "$*" != *pull_request_number=101* ]]; }`, + [{ number: 101, headRefOid: "aaa" }, { number: 102, headRefOid: "bbb" }]); assert.equal(result.status, 1, result.stderr); assert.match(result.stdout, /pull_request_number=101/); assert.match(result.stdout, /pull_request_number=102/); }); +test("refresh reruns the existing pull_request_target run in place instead of publishing a new check-run", () => { + const result = runRefreshLoop(`gh() { + case "$1 $2" in + "run list") [[ "$*" == *"--event pull_request_target"* && "$*" == *"--commit aaa"* ]] && echo 555; return 0 ;; + "run rerun") echo "rerun $3"; return 0 ;; + *) echo "$*"; return 0 ;; + esac }`, + [{ number: 101, headRefOid: "aaa" }]); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /^rerun 555$/m); + assert.doesNotMatch(result.stdout, /workflow run/); +}); + +test("refresh falls back to dispatch when no pull_request_target run exists or the rerun is refused", () => { + const result = runRefreshLoop(`gh() { + case "$1 $2" in + "run list") [[ "$*" == *"--commit aaa"* ]] && echo 555; return 0 ;; + "run rerun") echo "rerun refused" >&2; return 1 ;; + *) echo "$*"; return 0 ;; + esac }`, + [{ number: 101, headRefOid: "aaa" }, { number: 102, headRefOid: "bbb" }, { number: 103 }]); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /pull_request_number=101/); + assert.match(result.stdout, /pull_request_number=102/); + assert.match(result.stdout, /pull_request_number=103/); +}); + test("PR HEAD guard runs default-branch policy once, so no second verdict is republished (#1977)", () => { // pull_request 로 돌면 PR 사본 YAML 이 판정해 낡은 브랜치는 옛 정책이고 PR 이 가드를 고칠 수 // 있다. 종전 처방(inline 종료 → 최신 정책 dispatch → check-run 재게시)은 GITHUB_TOKEN 의 diff --git a/.github/workflows/merge-order-guard.yml b/.github/workflows/merge-order-guard.yml index 9956cf8f5..9848abfd1 100644 --- a/.github/workflows/merge-order-guard.yml +++ b/.github/workflows/merge-order-guard.yml @@ -27,7 +27,10 @@ on: issues: types: [closed, reopened] # GITHUB_TOKEN 이 만든 baseline/stack merge commit 은 pull_request_target 도 내지 않으므로, - # 그 SHA 와 선행 이슈가 닫힌 뒤의 PR HEAD 에는 dispatch 결과를 check-run 으로 붙인다. + # 그 SHA 에는 dispatch 결과를 check-run 으로 붙인다. 선행 이슈가 닫힌 뒤의 PR HEAD 는 이미 + # pull_request_target run 이 있으니 refresh 가 그 run 을 재실행하고, run 이 없을 때만 여기로 + # 내려온다 (#1983). PR HEAD 에 check-run 을 새로 만들면 임의 suite 에 얹혀 무관한 워크플로 + # 이름 아래 «guard» 가 또 뜬다. workflow_dispatch: inputs: pull_request_number: @@ -304,7 +307,7 @@ jobs: refresh: if: github.event_name == 'issues' permissions: - actions: write # 최신 default-branch 정책을 workflow_dispatch로 실행 + actions: write # 기존 pull_request_target run 재실행, 없으면 workflow_dispatch issues: read pull-requests: read runs-on: ubuntu-latest @@ -335,7 +338,7 @@ jobs: # 닫힐 때 같은 parser로 열린 PR을 다시 찾아야, 그 PR의 stale red check가 갱신된다. prs_file="$RUNNER_TEMP/merge-order-open-prs.json" gh api graphql --paginate --slurp \ - -f query='query($owner:String!,$repo:String!,$endCursor:String){repository(owner:$owner,name:$repo){pullRequests(states:OPEN,first:100,after:$endCursor){nodes{number title body closingIssuesReferences(first:20){nodes{number repository{nameWithOwner}}}} pageInfo{hasNextPage endCursor}}}}' \ + -f query='query($owner:String!,$repo:String!,$endCursor:String){repository(owner:$owner,name:$repo){pullRequests(states:OPEN,first:100,after:$endCursor){nodes{number headRefOid title body closingIssuesReferences(first:20){nodes{number repository{nameWithOwner}}}} pageInfo{hasNextPage endCursor}}}}' \ -f owner="$owner" -f repo="$repo" > "$prs_file" heads=$( BLOCKED="$blocked" PRS_FILE="$prs_file" node <<'NODE' @@ -377,8 +380,27 @@ jobs: exit 0 fi + # PR HEAD 에는 pull_request_target 으로 돈 guard run 이 이미 있다. 그 run 은 base 의 + # YAML 과 default branch 의 스크립트로 돌았고, 재실행도 스크립트를 default branch 에서 + # 다시 받으므로 같은 suite 안의 guard 가 제자리에서 갱신된다. dispatch 결과를 check-run + # 으로 새로 붙이면 임의 suite 에 얹혀 무관한 워크플로 이름 아래 guard 가 또 뜬다 (#1983). + # run 이 없거나(토큰 커밋) 재실행이 거절되면(진행 중·보존 기간 초과) dispatch 로 내려간다. + # HEAD SHA 는 위 GraphQL 결과에서 읽는다 — PR 마다 REST 를 또 부르면 한도를 태운다 (#1465). dispatch_failed=0 for pr in $heads; do + sha=$(jq -r --argjson n "$pr" \ + '[.[].data.repository.pullRequests.nodes[] | select(.number == $n) | .headRefOid][0] // empty' \ + "$prs_file") + run_id="" + if [ -n "$sha" ]; then + run_id=$(gh run list --workflow merge-order-guard.yml --event pull_request_target \ + --commit "$sha" --status completed --limit 1 --json databaseId \ + --jq '.[0].databaseId // empty' || true) + fi + if [ -n "$run_id" ] && gh run rerun "$run_id"; then + echo "PR #$pr: pull_request_target run $run_id 재실행 — 같은 suite 안에서 guard 갱신" + continue + fi if ! gh workflow run merge-order-guard.yml --ref "$DEFAULT_BRANCH" -f pull_request_number="$pr"; then echo "::error::PR #$pr 최신 가드 dispatch 실패" dispatch_failed=1