diff --git a/.github/scripts/create-ci-config.test.mjs b/.github/scripts/create-ci-config.test.mjs index ccb49950a..b6ecfc7fc 100644 --- a/.github/scripts/create-ci-config.test.mjs +++ b/.github/scripts/create-ci-config.test.mjs @@ -127,9 +127,9 @@ test("keeps pull request validation secretless and release credentials isolated" // pull_request_target은 default branch 정의에 쓰기 권한을 줄 수 있어 원칙적으로 금지한다. // 예외는 PR code/artifact/cache를 실행하지 않고 default branch 정책과 API JSON만 읽는 좁은 - // bridge 둘뿐이다 — 닫힌 스택 멤버 알림과 merge queue 방출 처리(#1892). 그 불변식을 둘 다에 - // 함께 고정한다. - const narrowBridges = ["merge-queue-dequeue.yml", "stack-integrity-notify.yml"]; + // bridge 셋뿐이다 — 닫힌 스택 멤버 알림, merge queue 방출 처리(#1892), 머지 순서 가드(#1977, + // PR 사본 YAML 로 판정하던 구멍과 재게시 guard 두 줄을 없앤다). 그 불변식을 셋 모두에 고정한다. + const narrowBridges = ["merge-order-guard.yml", "merge-queue-dequeue.yml", "stack-integrity-notify.yml"]; assert.deepEqual([...pullRequestTargetWorkflows].sort(), narrowBridges); for (const bridgeName of narrowBridges) { const bridge = workflows.get(bridgeName); diff --git a/.github/scripts/merge-order-guard-policy.test.mjs b/.github/scripts/merge-order-guard-policy.test.mjs index 927b36c3b..836a5b196 100644 --- a/.github/scripts/merge-order-guard-policy.test.mjs +++ b/.github/scripts/merge-order-guard-policy.test.mjs @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { readFile } from "node:fs/promises"; import { spawnSync } from "node:child_process"; import { tmpdir } from "node:os"; @@ -231,7 +231,7 @@ test("merge-group guard queries complete native-stack membership and fails close }); test("ordinary PR guard stays non-stale while merge queue performs the live verdict", () => { - assert.match(guard, /^\s{4}types: \[opened, reopened, synchronize, edited\]$/m); + assert.match(guard, /^\s{2}pull_request_target:\n\s{4}types: \[opened, reopened, synchronize, edited\]$/m); assert.doesNotMatch(guard, /statuses: write/); const start = guard.indexOf('if [ "$EVENT_NAME" = "merge_group" ]'); const end = guard.indexOf(" # GitHub 가 close keyword", start); @@ -434,12 +434,20 @@ gh() { echo "$*"; [[ "$*" != *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/); -}); +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 의 + // check-run 이 임의 suite 에 얹혀 무관한 워크플로 이름 아래 guard 가 두 줄로 떴다. + assert.doesNotMatch(guard, /^\s{2}pull_request:$/m); + assert.doesNotMatch(guard, /github\.event_name == 'pull_request'/); + assert.match(guard, /github\.event_name == 'pull_request_target'/); + assert.doesNotMatch(guard, /github\.event\.pull_request\.head\.(sha|ref)/); + assert.doesNotMatch(guard, /actions\/checkout[\s\S]*?ref: \$\{\{ github\.event\.pull_request/); + assert.match(guard, /ref: \$\{\{ github\.event\.repository\.default_branch \}\}/); + assert.ok( + !existsSync(new URL("../workflows/refresh-merge-order-policy.yml", import.meta.url)), + "inline 종료 뒤 최신 정책을 다시 dispatch 하는 refresh 워크플로는 pull_request_target 이 대체했다", + ); + // dispatch 재게시는 이벤트가 나지 않는 SHA(토큰 커밋·선행 이슈 닫힘) 전용으로만 남는다. + assert.match(guard, /github\.event_name == 'workflow_dispatch' &&\n\s+github\.ref_name == github\.event\.repository\.default_branch/); +}); \ No newline at end of file diff --git a/.github/scripts/remote-dev-workflows-policy.test.mjs b/.github/scripts/remote-dev-workflows-policy.test.mjs index fbe7b95f2..db964599c 100644 --- a/.github/scripts/remote-dev-workflows-policy.test.mjs +++ b/.github/scripts/remote-dev-workflows-policy.test.mjs @@ -406,5 +406,5 @@ test("required checks expose manual dispatch for token-authored commits", async assert.match(validation, /^\s{2}workflow_dispatch:$/m); assert.match(guard, /^\s{2}workflow_dispatch:\n\s{4}inputs:/m); - assert.match(guard, /github\.event_name == 'pull_request' \|\| github\.event_name == 'workflow_dispatch'/); + assert.match(guard, /github\.event_name == 'pull_request_target' \|\| github\.event_name == 'workflow_dispatch'/); }); diff --git a/.github/workflows/merge-order-guard.yml b/.github/workflows/merge-order-guard.yml index 12ca4f50e..9956cf8f5 100644 --- a/.github/workflows/merge-order-guard.yml +++ b/.github/workflows/merge-order-guard.yml @@ -7,10 +7,18 @@ # # 네이티브 스택의 닫힌 미병합 멤버는 merge queue synthetic SHA에서 live로 다시 검사한다. # 중간 멤버가 닫힌 직후의 조치 안내는 stack-integrity-notify가 상위 PR에 남긴다. +# +# 판정은 항상 default branch 의 정책으로 한다 (#1977). pull_request 로 돌리면 PR 사본의 +# 이 YAML 이 실행돼 낡은 브랜치는 옛 정책으로 판정하고, PR 이 가드를 고쳐 스스로 통과할 수 +# 있다. 종전에는 그 구멍을 inline 종료 뒤 최신 정책을 dispatch 해 check-run 으로 재게시하는 +# 두 번째 판정으로 메웠는데, GITHUB_TOKEN 이 만든 check-run 은 같은 SHA 의 임의 check +# suite 에 얹혀 무관한 워크플로 이름 아래 «guard» 가 두 줄로 떴다. pull_request_target 은 +# base 의 YAML 과 스크립트로 돌면서 check 는 PR HEAD 에 붙으므로 첫 판정이 곧 최종 판정이다. +# PR 코드는 체크아웃하지 않고 권한은 read 뿐이다(stack-integrity-notify 와 같은 경계). name: merge-order-guard on: - pull_request: + pull_request_target: types: [opened, reopened, synchronize, edited] # merge queue 는 큐 ref 에서 guard context 를 다시 요구한다. 큐 ref # (gh-readonly-queue//pr--) 에서 PR 번호를 파싱해 같은 검사를 돌린다. @@ -18,7 +26,8 @@ on: types: [checks_requested] issues: types: [closed, reopened] - # GITHUB_TOKEN 이 만든 baseline/stack merge commit 에도 최신 SHA 기준 guard 를 붙인다. + # GITHUB_TOKEN 이 만든 baseline/stack merge commit 은 pull_request_target 도 내지 않으므로, + # 그 SHA 와 선행 이슈가 닫힌 뒤의 PR HEAD 에는 dispatch 결과를 check-run 으로 붙인다. workflow_dispatch: inputs: pull_request_number: @@ -26,16 +35,19 @@ on: required: true type: number -permissions: - contents: read - issues: read - pull-requests: read +# pull_request_target 은 base 권한으로 돌므로 워크플로 기본 권한은 비우고 job 마다 필요한 +# 읽기·쓰기만 준다(create-ci-config 정책 테스트의 narrow bridge 불변식). +permissions: {} jobs: guard: if: >- - github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' || + github.event_name == 'pull_request_target' || github.event_name == 'workflow_dispatch' || github.event_name == 'merge_group' + permissions: + contents: read + issues: read + pull-requests: read runs-on: ubuntu-latest timeout-minutes: 5 outputs: @@ -56,8 +68,9 @@ jobs: printf 'sha=%s\n' "$(jq -r '.headRefOid' "$RUNNER_TEMP/guard-target.json")" >> "$GITHUB_OUTPUT" # 게이트 스크립트는 **default branch 사본**으로 돌린다. PR 사본을 체크아웃하면 PR 이 - # 자기를 검사하는 가드를 고쳐 통과시킬 수 있다. 필요한 건 .github/scripts 뿐이라 - # sparse-checkout 으로 좁힌다. + # 자기를 검사하는 가드를 고쳐 통과시킬 수 있다. pull_request_target 의 기본 ref 도 + # base 지만 dispatch·merge_group 경로와 같은 사본을 명시한다. 필요한 건 .github/scripts + # 뿐이라 sparse-checkout 으로 좁힌다. - name: Clone gate scripts from the default branch uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: diff --git a/.github/workflows/refresh-merge-order-policy.yml b/.github/workflows/refresh-merge-order-policy.yml deleted file mode 100644 index dd5044b3d..000000000 --- a/.github/workflows/refresh-merge-order-policy.yml +++ /dev/null @@ -1,39 +0,0 @@ -# 오래된 브랜치의 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"