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
91 changes: 91 additions & 0 deletions .github/scripts/pr-gate-policy.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
13 changes: 6 additions & 7 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
71 changes: 71 additions & 0 deletions .github/workflows/pr-validation.yml
Original file line number Diff line number Diff line change
@@ -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)"'
3 changes: 2 additions & 1 deletion .github/workflows/repository-quality.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
name: Repository Quality

# PR 에서는 pr-validation.yml 이 부른다. develop·main push 는 여기서 직접 돈다 (#848).
on:
pull_request:
workflow_call:
push:
branches: [develop, main]

Expand Down
13 changes: 6 additions & 7 deletions .github/workflows/screenshot.yml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
13 changes: 6 additions & 7 deletions .github/workflows/unit-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading