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"