diff --git a/.github/scripts/merge-order-guard-policy.test.mjs b/.github/scripts/merge-order-guard-policy.test.mjs index 31cf1b7e00..927b36c3ba 100644 --- a/.github/scripts/merge-order-guard-policy.test.mjs +++ b/.github/scripts/merge-order-guard-policy.test.mjs @@ -156,7 +156,7 @@ test("closing a blocker refreshes PRs found only through unlinked close keywords const heads = collectRefreshHeads( [ { - headRefName: "linked", + number: 101, title: "fix: linked", body: "", closingIssuesReferences: { @@ -166,13 +166,13 @@ test("closing a blocker refreshes PRs found only through unlinked close keywords }, }, { - headRefName: "text-only", + number: 102, title: "fix: text only", body: "Closes #1176", closingIssuesReferences: { nodes: [] }, }, { - headRefName: "foreign", + number: 103, title: "fix: foreign", body: "Closes other/repo#1176", closingIssuesReferences: { @@ -180,7 +180,7 @@ test("closing a blocker refreshes PRs found only through unlinked close keywords }, }, { - headRefName: "mention-only", + number: 104, title: "docs: mention #1176", body: "Related to #1176", closingIssuesReferences: { nodes: [] }, @@ -189,13 +189,13 @@ test("closing a blocker refreshes PRs found only through unlinked close keywords [1176], ); - assert.deepEqual(heads, ["linked", "text-only"]); + assert.deepEqual(heads, ["101", "102"]); }); 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\{headRefName title body closingIssuesReferences/); + assert.match(guard, /nodes\{number title body closingIssuesReferences/); assert.match(guard, /pageInfo\{hasNextPage endCursor\}/); }); @@ -203,7 +203,7 @@ test("refresh passes large open-PR payloads through a file instead of one enviro const heads = collectRefreshHeads( [ { - headRefName: "large-body", + number: 105, title: "fix: large body", body: `Closes #1176\n${"x".repeat(160 * 1024)}`, closingIssuesReferences: { nodes: [] }, @@ -212,7 +212,7 @@ test("refresh passes large open-PR payloads through a file instead of one enviro [1176], ); - assert.deepEqual(heads, ["large-body"]); + assert.deepEqual(heads, ["105"]); assert.match(guard, /> "\$prs_file"/); assert.doesNotMatch(guard, /PRS_JSON=/); }); @@ -320,3 +320,126 @@ printf '%s %s' "$PR_NUMBER" "$BASE_REF"`, assert.equal(result.status, 0, result.stderr); assert.equal(result.stdout, "1477 develop"); }); + +function shellStep(name) { + const start = guard.indexOf(` - name: ${name}\n`); + assert.ok(start >= 0, name); + const run = guard.indexOf(" run: |\n", start) + " run: |\n".length; + const lines = guard.slice(run).split("\n"); + const body = []; + for (const line of lines) { + if (line && !line.startsWith(" ")) break; + body.push(line.slice(10)); + } + return body.join("\n"); +} + +function runGuard({ base = "feature/parent", blockers = [], apiFailure = false, event = "pull_request" } = {}) { + const script = shellStep("머지 큐 스택 무결성·closing 이슈 blocked_by 검사"); + const fake = `gh() { + if [[ "$*" == *stackEntry* ]]; then printf '%s' '{"stackEntry":null}'; + elif [[ "$*" == *graphql* ]]; then printf '%s' "$FIXTURE_PR"; + elif [[ "$*" == *dependencies/blocked_by* ]]; then + [ "$FIXTURE_API_FAILURE" != "true" ] || return 42 + printf '%s' "$FIXTURE_OPEN_BLOCKERS" + else echo "Unexpected gh call: $*" >&2; return 99; fi + } +`; + return spawnSync("bash", ["-c", fake + script], { + encoding: "utf8", + env: { ...process.env, EVENT_NAME: event, PR_NUMBER: "1930", BASE_REF: base, + GITHUB_REPOSITORY: "Afternote/Afternote-FE", FIXTURE_API_FAILURE: String(apiFailure), + FIXTURE_OPEN_BLOCKERS: blockers.filter((b) => b.state === "open").map((b) => b.number).join("\n"), + FIXTURE_PR: JSON.stringify({ baseRefName: base, title: "fix: guard", body: "", + closingIssuesReferences: { nodes: [{ number: 1920, repository: { nameWithOwner: "Afternote/Afternote-FE" } }] } }) }, + }); +} + +for (const base of ["develop", "main", "feature/parent"]) { + test(`open blockers fail the executable guard for ${base}`, () => { + const result = runGuard({ base, blockers: [{ number: 708, state: "open" }] }); + assert.equal(result.status, 1, result.stderr); + assert.match(result.stdout, /::error::#1920.*#708/); + }); + test(`closed or absent blockers pass the executable guard for ${base}`, () => { + for (const blockers of [[], [{ number: 708, state: "closed" }]]) { + const result = runGuard({ base, blockers }); + assert.equal(result.status, 0, result.stderr); + assert.doesNotMatch(result.stdout, /::error::/); + } + }); +} + +test("dependency API failure cannot become a green stacked guard", () => { + const result = runGuard({ apiFailure: true }); + assert.equal(result.status, 42, result.stderr); +}); + +test("merge queue still fails on an open issue blocker", () => { + const result = runGuard({ event: "merge_group", base: "develop", blockers: [{ number: 708, state: "open" }] }); + assert.equal(result.status, 1, result.stderr); +}); + +test("blocker changes dispatch current default-branch policy instead of rerunning old workflow code", () => { + 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/); +}); + +test("dispatch publication refuses changed or closed HEAD and publishes failure on the captured HEAD", () => { + const script = shellStep("Publish trusted guard verdict on the captured PR HEAD"); + const directory = mkdtempSync(join(tmpdir(), "merge-order-publish-")); + try { + for (const [state, head, result, expected] of [ + ["OPEN", "original", "failure", "failure"], + ["OPEN", "original", "success", "success"], + ["OPEN", "original", "cancelled", "failure"], + ["OPEN", "changed", "success", null], + ["CLOSED", "original", "success", null], + ]) { + const run = spawnSync("bash", ["-c", `gh() { + if [ "$1" = "pr" ]; then printf '%s' "$FIXTURE_CURRENT"; + elif [ "$1" = "api" ]; then cat "$RUNNER_TEMP/guard-check.json" >&2; + else return 99; fi + } +` + script], { encoding: "utf8", env: { ...process.env, + RUNNER_TEMP: directory, PR_NUMBER: "1930", TARGET_SHA: "original", GUARD_RESULT: result, + GITHUB_SERVER_URL: "https://github.com", GITHUB_REPOSITORY: "Afternote/Afternote-FE", GITHUB_RUN_ID: "123", + FIXTURE_CURRENT: JSON.stringify({ state, headRefOid: head }) } }); + assert.equal(run.status, 0, run.stderr); + if (expected === null) assert.equal(run.stderr, ""); + else { + const check = JSON.parse(run.stderr); + assert.equal(check.name, "guard"); + assert.equal(check.head_sha, "original"); + assert.equal(check.conclusion, expected); + } + } + } finally { rmSync(directory, { recursive: true, force: true }); } +}); + + +test("one failed refresh dispatch does not prevent the remaining PRs from refreshing", () => { + 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" +DEFAULT_BRANCH=develop +gh() { echo "$*"; [[ "$*" != *pull_request_number=101* ]]; } +` + script], { encoding: "utf8" }); + assert.equal(result.status, 1, result.stderr); + assert.match(result.stdout, /pull_request_number=101/); + assert.match(result.stdout, /pull_request_number=102/); +}); + +test("old PR workflows trigger a trusted default-branch refresh without dispatch loops or PR checkout", async () => { + const refresh = await readFile(new URL("../workflows/refresh-merge-order-policy.yml", import.meta.url), "utf8"); + assert.match(refresh, /workflow_run:\n\s+workflows: \[merge-order-guard\]\n\s+types: \[completed\]/); + assert.match(refresh, /github.event.workflow_run.event == 'pull_request'/); + assert.match(refresh, /^permissions: \{\}$/m); + assert.match(refresh, /select\(\.headRefOid == \$sha\)/); + assert.match(refresh, /gh workflow run merge-order-guard.yml --ref "\$DEFAULT_BRANCH"/); + assert.doesNotMatch(refresh, /uses:.*checkout|checks: write|contents: write|pull_request_target/); +}); diff --git a/.github/workflows/merge-order-guard.yml b/.github/workflows/merge-order-guard.yml index 6066b822cd..12ca4f50e4 100644 --- a/.github/workflows/merge-order-guard.yml +++ b/.github/workflows/merge-order-guard.yml @@ -3,7 +3,7 @@ # GitHub 는 이슈 Relationships(blocked_by)를 표시만 하고 머지 버튼을 잠그지 않는다. # 이 워크플로는 PR 의 closing 이슈에 아직 열린 blocked_by 가 있으면 check 를 # 실패시킨다. branch protection 에서 required check 로 지정하면 머지 버튼이 -# 실제로 잠긴다. base 가 develop/main 이 아닌 스택 PR 은 경고로만 남긴다. +# 실제로 잠긴다. 부모 브랜치 대상 스택 PR에도 같은 실패 판정을 적용한다. # # 네이티브 스택의 닫힌 미병합 멤버는 merge queue synthetic SHA에서 live로 다시 검사한다. # 중간 멤버가 닫힌 직후의 조치 안내는 stack-integrity-notify가 상위 PR에 남긴다. @@ -38,7 +38,23 @@ jobs: github.event_name == 'merge_group' runs-on: ubuntu-latest timeout-minutes: 5 + outputs: + target_sha: ${{ steps.target.outputs.sha }} steps: + - name: Capture dispatch target HEAD + if: github.event_name == 'workflow_dispatch' + id: target + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + PR_NUMBER: ${{ inputs.pull_request_number }} + run: | + set -euo pipefail + [[ "$PR_NUMBER" =~ ^[1-9][0-9]*$ ]] + gh pr view "$PR_NUMBER" --json state,headRefOid > "$RUNNER_TEMP/guard-target.json" + jq -e '.state == "OPEN"' "$RUNNER_TEMP/guard-target.json" > /dev/null + printf 'sha=%s\n' "$(jq -r '.headRefOid' "$RUNNER_TEMP/guard-target.json")" >> "$GITHUB_OUTPUT" + # 게이트 스크립트는 **default branch 사본**으로 돌린다. PR 사본을 체크아웃하면 PR 이 # 자기를 검사하는 가드를 고쳐 통과시킬 수 있다. 필요한 건 .github/scripts 뿐이라 # sparse-checkout 으로 좁힌다. @@ -107,10 +123,8 @@ jobs: BASE_REF=$(jq -r '.baseRefName' <<< "$pr_json") fi - # 스택 PR 안내 — base 가 부모 브랜치면 애초에 develop 로 머지되지 않고, - # 부모가 머지되면 GitHub 가 base 를 develop 로 이관하며 이 가드가 다시 - # 돌아 blocked_by 를 검사한다. 실패로 올리면 스택 PR 이 수명 내내 X 로 - # 남아 목록에서 진짜 CI 실패와 구분되지 않으므로 경고로만 남긴다. + # base는 스택 등록 안내에만 사용한다. 열린 이슈 선행은 base와 무관하게 + # 실패하며, 선행 이슈가 닫히면 최신 정책을 dispatch해 결과를 갱신한다. stacked=0 case "$BASE_REF" in develop|main) ;; @@ -215,15 +229,8 @@ jobs: open_blockers=$(gh api "repos/$GITHUB_REPOSITORY/issues/$n/dependencies/blocked_by" \ --jq '.[] | select(.state == "open") | .number') for b in $open_blockers; do - if [ "$stacked" -eq 1 ]; then - # 스택 PR 은 develop 로 직접 머지되지 않는다. base 가 develop 로 - # 이관되는 순간 이 가드가 다시 돌아 error 로 막으므로, 그전까지는 - # 경고로만 남겨 진짜 CI 실패와 구분되게 한다. - echo "::warning::#$n 이 아직 열린 #$b 에 blocked — base 를 develop 로 이관하기 전에 선행 PR 부터 머지할 것" - else - echo "::error::#$n 이 아직 열린 #$b 에 blocked — 머지 순서 위반. 선행 PR 부터 머지할 것" - fail=1 - fi + echo "::error::#$n 이 아직 열린 #$b 에 blocked — 머지 순서 위반. 선행 이슈를 먼저 완료할 것 (base: $BASE_REF)" + fail=1 done done 3<<< "$refs" @@ -245,10 +252,46 @@ jobs: node .github/scripts/ensure-api-quota.mjs classify env: GH_TOKEN: ${{ github.token }} + publish-dispatch-result: + if: >- + always() && github.event_name == 'workflow_dispatch' && + github.ref_name == github.event.repository.default_branch && needs.guard.outputs.target_sha != '' + needs: guard + permissions: + checks: write + pull-requests: read + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Publish trusted guard verdict on the captured PR HEAD + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + PR_NUMBER: ${{ inputs.pull_request_number }} + TARGET_SHA: ${{ needs.guard.outputs.target_sha }} + GUARD_RESULT: ${{ needs.guard.result }} + run: | + set -euo pipefail + current=$(gh pr view "$PR_NUMBER" --json state,headRefOid) + if [ "$(jq -r '.state' <<< "$current")" != "OPEN" ] || + [ "$(jq -r '.headRefOid' <<< "$current")" != "$TARGET_SHA" ]; then + echo "::notice::PR closed or HEAD changed; do not publish a stale verdict" + exit 0 + fi + conclusion=failure + [ "$GUARD_RESULT" != "success" ] || conclusion=success + details_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" + jq -n --arg sha "$TARGET_SHA" --arg conclusion "$conclusion" --arg url "$details_url" \ + '{name:"guard", head_sha:$sha, status:"completed", conclusion:$conclusion, + details_url:$url, output:{title:"merge-order-guard", summary:"Latest default-branch policy. See the linked run for blocker details."}}' \ + > "$RUNNER_TEMP/guard-check.json" + gh api "repos/$GITHUB_REPOSITORY/check-runs" --method POST \ + --input "$RUNNER_TEMP/guard-check.json" > /dev/null + refresh: if: github.event_name == 'issues' permissions: - actions: write # gh run rerun — 필요한 이 잡에만 job-level 로 한정 + actions: write # 최신 default-branch 정책을 workflow_dispatch로 실행 issues: read pull-requests: read runs-on: ubuntu-latest @@ -261,6 +304,7 @@ jobs: # gh run list/rerun 의 저장소 컨텍스트가 이 env 로만 잡힌다. GH_REPO: ${{ github.repository }} ISSUE: ${{ github.event.issue.number }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} run: | set -euo pipefail @@ -278,7 +322,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{headRefName 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 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' @@ -307,7 +351,7 @@ jobs: } if ([...issues].some((number) => blocked.has(number))) { - heads.add(pr.headRefName); + heads.add(pr.number); } } @@ -320,11 +364,11 @@ jobs: exit 0 fi - for h in $heads; do - run_id=$(gh run list --workflow merge-order-guard.yml --branch "$h" \ - --limit 1 --json databaseId --jq '.[0].databaseId // empty') - if [ -n "$run_id" ]; then - # 진행 중 런 재실행 거절 등은 치명 아님 — 경고로만 남긴다 - gh run rerun "$run_id" || echo "::warning::run $run_id 재실행 실패 — 진행 중이거나 권한 문제" + dispatch_failed=0 + for pr in $heads; do + 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 fi done + exit "$dispatch_failed" diff --git a/.github/workflows/refresh-merge-order-policy.yml b/.github/workflows/refresh-merge-order-policy.yml new file mode 100644 index 0000000000..dd5044b3de --- /dev/null +++ b/.github/workflows/refresh-merge-order-policy.yml @@ -0,0 +1,39 @@ +# 오래된 브랜치의 inline guard가 끝나도 최신 default-branch 정책으로 최종 결과를 갱신한다. +# PR 코드를 checkout/실행하지 않으며, workflow_dispatch 결과는 재호출하지 않는다. +name: Refresh merge-order policy + +on: + workflow_run: + workflows: [merge-order-guard] + types: [completed] + +permissions: {} + +jobs: + refresh: + if: github.event.workflow_run.event == 'pull_request' + permissions: + actions: write + pull-requests: read + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Dispatch latest policy for matching open PRs + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }} + HEAD_SHA: ${{ github.event.workflow_run.head_sha }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: | + set -euo pipefail + prs=$(gh pr list --state open --head "$HEAD_BRANCH" --limit 100 --json number,headRefOid) + numbers=$(jq -r --arg sha "$HEAD_SHA" '.[] | select(.headRefOid == $sha) | .number' <<< "$prs") + failed=0 + for pr in $numbers; do + if ! gh workflow run merge-order-guard.yml --ref "$DEFAULT_BRANCH" -f pull_request_number="$pr"; then + echo "::error::PR #$pr 최신 머지 가드 실행 요청 실패" + failed=1 + fi + done + exit "$failed"