Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 131 additions & 8 deletions .github/scripts/merge-order-guard-policy.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -166,21 +166,21 @@ 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: {
nodes: [{ number: 1176, repository: { nameWithOwner: "other/repo" } }],
},
},
{
headRefName: "mention-only",
number: 104,
title: "docs: mention #1176",
body: "Related to #1176",
closingIssuesReferences: { nodes: [] },
Expand All @@ -189,21 +189,21 @@ 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\}/);
});

test("refresh passes large open-PR payloads through a file instead of one environment value", () => {
const heads = collectRefreshHeads(
[
{
headRefName: "large-body",
number: 105,
title: "fix: large body",
body: `Closes #1176\n${"x".repeat(160 * 1024)}`,
closingIssuesReferences: { nodes: [] },
Expand All @@ -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=/);
});
Expand Down Expand Up @@ -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/);
});
90 changes: 67 additions & 23 deletions .github/workflows/merge-order-guard.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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에 남긴다.
Expand Down Expand Up @@ -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 으로 좁힌다.
Expand Down Expand Up @@ -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) ;;
Expand Down Expand Up @@ -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"

Expand All @@ -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
Expand All @@ -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

Expand All @@ -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'
Expand Down Expand Up @@ -307,7 +351,7 @@ jobs:
}

if ([...issues].some((number) => blocked.has(number))) {
heads.add(pr.headRefName);
heads.add(pr.number);
}
}

Expand All @@ -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"
39 changes: 39 additions & 0 deletions .github/workflows/refresh-merge-order-policy.yml
Original file line number Diff line number Diff line change
@@ -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"
Loading