From bb76cc0cee35e8e6df96c062432bf9a5f635c3ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Mon, 24 Aug 2026 19:24:13 +0900 Subject: [PATCH] =?UTF-8?q?ci:=20PR=20=EA=B2=80=EC=A6=9D=EC=9D=84=20?= =?UTF-8?q?=EB=8B=A8=EC=9D=BC=20required=20gate=20=EB=A1=9C=20=EB=AC=B6?= =?UTF-8?q?=EB=8A=94=EB=8B=A4=20=E2=80=94=20ci-gate=20=EC=A7=91=EA=B3=84?= =?UTF-8?q?=20+=20PR=20=EB=8B=A8=EC=9C=84=20=EC=B7=A8=EC=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ruleset 의 required check 는 guard 하나뿐이라 lint·unit-test·screenshot· repository-quality 는 실패해도 머지를 막지 못했다. 개별 workflow·job 이름을 required 로 지정하는 방식은 이름이 바뀔 때마다 게이트가 조용히 사라지므로, 요구하는 context 를 집계 job 하나로 고정한다. - pr-validation.yml 신설. PR 검증의 유일한 진입점이며 네 검증을 reusable workflow 로 부른다. concurrency 를 PR 단위로 걸어(cancel-in-progress: true) 새 commit 이 오면 옛 SHA 의 실행을 끊는다. - ci-gate job 은 if: always() 로 돌면서 needs 의 conclusion 을 명시적으로 판정한다. success 가 아닌 것은 전부 실패다 — failure·cancelled 는 물론 skipped 도 통과로 오인하지 않는다. - lint·unit-test·screenshot 은 on: workflow_call 전용으로 바꾼다. on: pull_request 를 함께 남기면 같은 검증이 두 벌 돌고 ci-gate 가 보지 않는 쪽이 별개 체크로 남는다. repository-quality 는 develop·main push 경로가 있으므로 push 트리거와 자체 concurrency 를 유지한다. - pull_request 에 branch 필터를 두지 않는다. base 변경(feat/* → develop)은 기본 activity type 에 없어 재트리거되지 않으므로, 필터가 있으면 스택 PR 이 검증을 한 번도 거치지 않고 머지된다 (#683). CodeQL 은 이슈 범위대로 넣지 않았다 — 안정화 후 별도 required check 로 본다. Kover 수치는 report-only 이며 gate 조건이 아니다. 실측: - actionlint · repository-quality.sh (16 workflows, 4 shell scripts) 통과 - node --test .github/scripts/*.test.mjs 통과 (신규 pr-gate-policy 7건 포함) ruleset 에 ci-gate 를 required 로 추가하는 것은 이 PR 이 develop 에 들어간 뒤다. develop 에 없는 context 를 required 로 걸면 열려 있는 PR 전부가 영원히 pending 이 된다. closes #848 --- .github/scripts/pr-gate-policy.test.mjs | 91 ++++++++++++++++++++++++ .github/workflows/lint.yml | 13 ++-- .github/workflows/pr-validation.yml | 71 ++++++++++++++++++ .github/workflows/repository-quality.yml | 3 +- .github/workflows/screenshot.yml | 13 ++-- .github/workflows/unit-test.yml | 13 ++-- 6 files changed, 182 insertions(+), 22 deletions(-) create mode 100644 .github/scripts/pr-gate-policy.test.mjs create mode 100644 .github/workflows/pr-validation.yml diff --git a/.github/scripts/pr-gate-policy.test.mjs b/.github/scripts/pr-gate-policy.test.mjs new file mode 100644 index 0000000000..2b7dca0000 --- /dev/null +++ b/.github/scripts/pr-gate-policy.test.mjs @@ -0,0 +1,91 @@ +import assert from "node:assert/strict"; +import { readdir, readFile } from "node:fs/promises"; +import test from "node:test"; + +const workflowDirectory = new URL("../workflows/", import.meta.url); +const GATE_WORKFLOW = "pr-validation.yml"; +// ci-gate 가 결과를 모아야 하는 검증들. 여기서 빠지면 그 검증은 머지를 막지 못한다. +const VALIDATION_WORKFLOWS = ["lint.yml", "unit-test.yml", "screenshot.yml", "repository-quality.yml"]; + +async function workflows() { + const names = (await readdir(workflowDirectory)).filter((name) => name.endsWith(".yml")); + return Promise.all(names.map(async (name) => [name, await readFile(new URL(name, workflowDirectory), "utf8")])); +} + +function readWorkflow(name) { + return readFile(new URL(name, workflowDirectory), "utf8"); +} + +function jobNames(source) { + const jobsSection = source.slice(source.indexOf("\njobs:\n")); + return [...jobsSection.matchAll(/^ {2}([A-Za-z][\w-]*):$/gm)].map((match) => match[1]); +} + +function needsOf(source, job) { + const pattern = new RegExp(`^ {2}${job}:$[\\s\\S]*?^ {4}needs:\\s*\\[([^\\]]*)\\]`, "m"); + const match = pattern.exec(source); + return match ? match[1].split(",").map((entry) => entry.trim()) : []; +} + +test("pull request validation has exactly one entry point", async () => { + // 검증 워크플로가 스스로 pull_request 를 듣고 있으면 같은 검사가 두 벌 돌고, + // ci-gate 가 보지 않는 쪽이 별개 체크로 남는다. + const entryPoints = (await workflows()) + .filter(([name, source]) => /^on:\n(?:[^\n]*\n)*?\s{2}pull_request:/m.test(source) && VALIDATION_WORKFLOWS.includes(name)) + .map(([name]) => name); + + assert.deepEqual(entryPoints, []); + assert.match(await readWorkflow(GATE_WORKFLOW), /^on:\n\s{2}pull_request:$/m); +}); + +test("every validation workflow is reachable only as a reusable call", async () => { + for (const name of VALIDATION_WORKFLOWS) { + assert.match(await readWorkflow(name), /^\s{2}workflow_call:$/m, `${name} must be callable`); + } +}); + +test("ci-gate aggregates every validation job in the entry workflow", async () => { + const gate = await readWorkflow(GATE_WORKFLOW); + const jobs = jobNames(gate); + + assert.ok(jobs.includes("ci-gate")); + const validationJobs = jobs.filter((job) => job !== "ci-gate"); + assert.equal(validationJobs.length, VALIDATION_WORKFLOWS.length); + // 검증 job 을 추가하고 needs 갱신을 잊으면 그 검증은 게이트 밖에 남는다. + assert.deepEqual(needsOf(gate, "ci-gate").sort(), [...validationJobs].sort()); + + for (const workflow of VALIDATION_WORKFLOWS) { + assert.ok(gate.includes(`uses: ./.github/workflows/${workflow}`), `${workflow} is not called`); + } +}); + +test("ci-gate runs after failures and judges each conclusion explicitly", async () => { + const gate = await readWorkflow(GATE_WORKFLOW); + + assert.match(gate, /^\s{4}if: always\(\)$/m); + // always() 로 돌기만 하고 아무것도 검사하지 않으면 게이트가 항상 초록이 된다. + assert.match(gate, /toJSON\(needs\)/); + assert.match(gate, /\.value\.result != "success"/); + assert.match(gate, /exit 1/); +}); + +test("stale runs are cancelled per pull request", async () => { + const gate = await readWorkflow(GATE_WORKFLOW); + + assert.match(gate, /^concurrency:\n\s{2}group: pr-validation-\$\{\{ github\.event\.pull_request\.number \|\| github\.ref \}\}\n\s{2}cancel-in-progress: true$/m); +}); + +test("the entry point keeps no pull_request branch filter", async () => { + // #683: base 변경(feat/* → develop)은 기본 activity type 에 없어 재트리거되지 + // 않는다. 필터가 있으면 스택 PR 이 검증을 한 번도 거치지 않고 머지된다. + const gate = await readWorkflow(GATE_WORKFLOW); + const trigger = /^on:\n\s{2}pull_request:\n([\s\S]*?)^\S/m.exec(gate)?.[1] ?? ""; + + assert.doesNotMatch(trigger, /branches/); +}); + +test("repository quality still runs on develop and main pushes", async () => { + const source = await readWorkflow("repository-quality.yml"); + + assert.match(source, /^\s{2}push:\n\s{4}branches:\s*\[develop, main\]$/m); +}); diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 7337f1548d..ba40e3f439 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -1,13 +1,12 @@ name: Static Analysis (Reviewdog) -# base 필터를 두지 않는다 — base 변경(feat/* → develop)은 pull_request 기본 activity type 에 -# 없어 재트리거되지 않으므로, 필터가 있으면 스택 PR 이 검증을 한 번도 거치지 않고 머지된다. +# PR 검증은 pr-validation.yml 이 한 번에 부르고, 그 워크플로의 ci-gate job 이 결과를 +# 모아 단일 required check 로 만든다. concurrency 도 그쪽이 PR 단위로 건다 (#848). +# +# 트리거를 workflow_call 하나로 두는 것이 요점이다 — on: pull_request 를 함께 남기면 +# 같은 검증이 PR 마다 두 벌씩 돌고, ci-gate 가 보지 않는 쪽이 별개 체크로 남는다. on: - pull_request: - -concurrency: - group: static-analysis-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true + workflow_call: permissions: contents: read diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml new file mode 100644 index 0000000000..41f873c645 --- /dev/null +++ b/.github/workflows/pr-validation.yml @@ -0,0 +1,71 @@ +name: PR Validation + +# PR 검증의 유일한 진입점이다. static analysis · unit test · screenshot · +# repository quality 를 reusable workflow 로 부르고, ci-gate job 이 그 결과를 모아 +# 하나의 required check 로 만든다 (#848). +# +# ruleset 이 개별 workflow·job 이름을 required 로 지정하면 이름이 바뀔 때마다 +# 게이트가 조용히 사라진다. 요구하는 context 를 ci-gate 하나로 고정해 그 취약성을 없앤다. +# +# base 필터를 두지 않는다 — base 변경(feat/* → develop)은 pull_request 기본 activity +# type 에 없어 재트리거되지 않으므로, 필터가 있으면 스택 PR 이 검증을 한 번도 거치지 +# 않고 머지된다. +on: + pull_request: + +# 같은 PR 의 옛 실행을 끊는다. head 가 갱신되면 이전 SHA 의 결과는 판단 근거가 아니다. +concurrency: + group: pr-validation-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + pull-requests: write + +jobs: + static-analysis: + name: Static Analysis + uses: ./.github/workflows/lint.yml + + unit-test: + name: Unit Test + uses: ./.github/workflows/unit-test.yml + + screenshot: + name: Screenshot + uses: ./.github/workflows/screenshot.yml + + repository-quality: + name: Repository Quality + uses: ./.github/workflows/repository-quality.yml + + # ruleset 이 요구하는 단 하나의 context. 선행 job 의 conclusion 을 명시적으로 검사한다. + ci-gate: + name: ci-gate + if: always() + needs: [static-analysis, unit-test, screenshot, repository-quality] + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + # if: always() 는 선행 job 이 실패해도 이 job 을 돌린다. 그 상태에서 아무것도 + # 검사하지 않으면 게이트가 항상 초록이 되므로, needs 의 result 를 직접 판정한다. + # success 가 아닌 것은 전부 실패다 — failure·cancelled 는 물론, skipped 도 + # 의도한 통과가 아니다(선행 job 이 조건에서 빠지면 검증되지 않은 채 머지된다). + - name: Require every validation job to have succeeded + env: + VALIDATION_RESULTS: ${{ toJSON(needs) }} + run: | + set -euo pipefail + + failures=$(printf '%s' "$VALIDATION_RESULTS" \ + | jq -r 'to_entries[] | select(.value.result != "success") | "\(.key) = \(.value.result)"') + + if [ -n "$failures" ]; then + while IFS= read -r failure; do + echo "::error::PR 검증 실패 — $failure" + done <<< "$failures" + exit 1 + fi + + printf '%s' "$VALIDATION_RESULTS" \ + | jq -r 'to_entries[] | "\(.key) = \(.value.result)"' diff --git a/.github/workflows/repository-quality.yml b/.github/workflows/repository-quality.yml index c0b2c42e48..527fd4fa3f 100644 --- a/.github/workflows/repository-quality.yml +++ b/.github/workflows/repository-quality.yml @@ -1,7 +1,8 @@ name: Repository Quality +# PR 에서는 pr-validation.yml 이 부른다. develop·main push 는 여기서 직접 돈다 (#848). on: - pull_request: + workflow_call: push: branches: [develop, main] diff --git a/.github/workflows/screenshot.yml b/.github/workflows/screenshot.yml index bc5756f396..d8ff1506f6 100644 --- a/.github/workflows/screenshot.yml +++ b/.github/workflows/screenshot.yml @@ -1,13 +1,12 @@ name: Compose Preview Screenshot Test -# base 필터를 두지 않는다 — base 변경(feat/* → develop)은 pull_request 기본 activity type 에 -# 없어 재트리거되지 않으므로, 필터가 있으면 스택 PR 이 검증을 한 번도 거치지 않고 머지된다. +# PR 검증은 pr-validation.yml 이 한 번에 부르고, 그 워크플로의 ci-gate job 이 결과를 +# 모아 단일 required check 로 만든다. concurrency 도 그쪽이 PR 단위로 건다 (#848). +# +# 트리거를 workflow_call 하나로 두는 것이 요점이다 — on: pull_request 를 함께 남기면 +# 같은 검증이 PR 마다 두 벌씩 돌고, ci-gate 가 보지 않는 쪽이 별개 체크로 남는다. on: - pull_request: - -concurrency: - group: screenshot-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true + workflow_call: permissions: contents: read diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml index 523882580f..1118f525d1 100644 --- a/.github/workflows/unit-test.yml +++ b/.github/workflows/unit-test.yml @@ -10,14 +10,13 @@ name: Unit Test # 머지 전 어디서도 검출되지 않았다(#936 — develop 컴파일 실패 44건). 에뮬레이터 없이 컴파일만 검증. # lint.yml 과 setup 이 거의 동일하지만 분리 — lint 실패가 test 결과를 가리지 않도록 fail-fast 독립. -# base 필터를 두지 않는다 — base 변경(feat/* → develop)은 pull_request 기본 activity type 에 -# 없어 재트리거되지 않으므로, 필터가 있으면 스택 PR 이 검증을 한 번도 거치지 않고 머지된다. +# PR 검증은 pr-validation.yml 이 한 번에 부르고, 그 워크플로의 ci-gate job 이 결과를 +# 모아 단일 required check 로 만든다. concurrency 도 그쪽이 PR 단위로 건다 (#848). +# +# 트리거를 workflow_call 하나로 두는 것이 요점이다 — on: pull_request 를 함께 남기면 +# 같은 검증이 PR 마다 두 벌씩 돌고, ci-gate 가 보지 않는 쪽이 별개 체크로 남는다. on: - pull_request: - -concurrency: - group: unit-test-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true + workflow_call: permissions: contents: read