From 0bc4358e902e044ee91d9232cc689801648ac739 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Sun, 6 Sep 2026 16:29:02 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EB=B0=B0=ED=8F=AC=20=EB=85=B8=ED=8A=B8?= =?UTF-8?q?=EB=A5=BC=20PR=EC=97=90=EC=84=9C=20=EA=B2=80=EC=A6=9D=ED=95=98?= =?UTF-8?q?=EA=B3=A0=20=EC=BD=94=EB=93=9C=20=EB=B8=94=EB=A1=9D=EC=9D=84=20?= =?UTF-8?q?=EC=95=88=EC=A0=84=ED=95=98=EA=B2=8C=20=EC=A0=9C=EA=B1=B0?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../render-distribution-release-notes.sh | 149 ++++++++++---- ...render-distribution-release-notes.test.mjs | 190 +++++++++++++++++- .github/workflows/repository-quality.yml | 15 ++ 3 files changed, 318 insertions(+), 36 deletions(-) diff --git a/.github/scripts/render-distribution-release-notes.sh b/.github/scripts/render-distribution-release-notes.sh index 4bf30431..a4390687 100755 --- a/.github/scripts/render-distribution-release-notes.sh +++ b/.github/scripts/render-distribution-release-notes.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # Firebase App Distribution 릴리스 노트를 만든다. -# push(main 머지) : 머지된 PR 본문의 「📌 Issues」·「📎 Work Description」 섹션에서 뽑는다. +# push / pull_request : PR 본문의 「📌 Issues」·「📎 Work Description」 섹션에서 뽑는다. # workflow_dispatch : ISSUE_NUMBERS · RELEASE_NOTES 입력에서 뽑는다 (WIF canary). # 이슈 번호와 변경 내용이 하나도 없으면 업로드 전에 실패한다 — 테스터가 무엇을 확인할지 모르는 # 배포는 내보내지 않는다. @@ -13,32 +13,79 @@ event_name="${EVENT_NAME:-}" issue_numbers="${ISSUE_NUMBERS:-}" release_notes="${RELEASE_NOTES:-}" -# PR 템플릿의 헤더는 이모지 + 수학 이탤릭 유니코드(📌𝘐𝘴𝘴𝘶𝘦𝘴 · 📎𝘞𝘰𝘳𝘬 𝘋𝘦𝘴𝘤𝘳𝘪𝘱𝘵𝘪𝘰𝘯) 라 이모지와 -# 일반 ASCII 표기 둘 다 받는다. -extract_section() { - local marker="$1" - local ascii_marker="$2" - local body_file="$3" - - # 제목은 「# 뒤에 공백」 이 있는 줄뿐이다(마크다운 ATX). 이 구분이 없으면 「#130 에서 …」 처럼 - # 이슈 번호로 시작하는 본문 줄이 제목으로 읽혀 섹션이 거기서 끊긴다. 그러면 Work Description - # 이 비어 배포가 「변경 내용이 하나 이상 필요합니다」 로 막힌다(#159). - awk -v marker="$marker" -v ascii_marker="$ascii_marker" ' - /^#+[[:space:]]/ { - heading = $0 - if (capturing) { - exit +# 섹션 경계를 찾기 전에 코드·주석을 걷어 낸다. 코드 안의 제목은 PR 제목이 아니다. +# 닫는 fence 는 여는 fence 와 같은 문자로, 길이가 같거나 길어야 한다. +strip_blocks() { + awk ' + { + line = $0 + sub(/\r$/, "", line) + fence = line + sub(/^[[:space:]]*/, "", fence) + if (fence_char != "") { + if (substr(fence, 1, 1) == fence_char) { + run = fence + sub(fence_char == "`" ? "[^`].*$" : "[^~].*$", "", run) + tail = substr(fence, length(run) + 1) + if (length(run) >= fence_length && tail ~ /^[[:space:]]*$/) { + fence_char = "" + } + } + next + } + # 주석 안의 fence 는 코드 블록을 열지 않는다. 같은 줄의 보이는 글은 보존한다. + visible = "" + while (length(line)) { + if (in_comment) { + finish = index(line, "-->") + if (!finish) { line = ""; break } + line = substr(line, finish + 3) + in_comment = 0 + } else { + begin = index(line, "/) { - in_comment = 0 - } + # 수평선(--- · *** · ___). 목록 표시를 떼기 전에 걸러야 빈 항목으로 남지 않는다. + /^[[:space:]]*([-*_][[:space:]]*){3,}$/ { + next + } + + # HTML 로 시작하는 줄.
같은 접기 표시가 그대로 나가면 뜻이 없다. + /^[[:space:]]*<[a-zA-Z\/!]/ { next } { point = $0 + sub(/^[[:space:]]*>[[:space:]]*/, "", point) # 목록 표시는 「- 」 나 「* 」 처럼 뒤에 공백이 온다. 공백을 요구하지 않으면 # 「**굵게** 로 시작하는 문단」 의 별표 하나를 목록 표시로 보고 잘라 낸다(#159). sub(/^[[:space:]]*[-*][[:space:]]+/, "", point) # 내용 없이 표시만 있는 줄. 이 줄은 비운 것으로 봐야 아래 검사가 걸러 낸다. sub(/^[[:space:]]*[-*][[:space:]]*$/, "", point) sub(/^[[:space:]]*[0-9]+\.[[:space:]]*/, "", point) + # 체크 상자는 표시만 떼고 글자는 남긴다. + sub(/^\[[ xX]\][[:space:]]*/, "", point) + + # 그림은 통째로 뺀다. 대체 글자만 남으면 무엇을 가리키는지 알 수 없다. + gsub(/!\[[^]]*\]\([^)]*\)/, "", point) + # 링크는 글자만 남긴다. 주소는 테스터가 열 수 없는 자리가 많다. + while (match(point, /\[[^]]*\]\([^)]*\)/)) { + chunk = substr(point, RSTART, RLENGTH) + label = substr(chunk, 2, index(chunk, "](") - 2) + point = substr(point, 1, RSTART - 1) label substr(point, RSTART + RLENGTH) + } + # 인라인 코드와 굵게·기울임 표기. 글자만 남기고 기호는 뺀다. + gsub(/`/, "", point) + gsub(/\*\*/, "", point) + gsub(/__/, "", point) + # 기울임은 짝이 있는 표시만 벗긴다. snake_case 같은 식별자는 보존한다. + while (match(point, /\*[^*]+\*/)) { + point = substr(point, 1, RSTART - 1) substr(point, RSTART + 1, RLENGTH - 2) substr(point, RSTART + RLENGTH) + } + while (match(point, /(^|[[:space:]])_[^_]+_([[:space:][:punct:]]|$)/)) { + chunk = substr(point, RSTART, RLENGTH) + sub(/_/, "", chunk) + sub(/_/, "", chunk) + point = substr(point, 1, RSTART - 1) chunk substr(point, RSTART + RLENGTH) + } + sub(/^[[:space:]]+/, "", point) sub(/[[:space:]]+$/, "", point) @@ -123,7 +204,7 @@ normalized_notes="$( if [[ -z "$normalized_issues" ]]; then printf '::error::Issues 섹션에는 #123 형식의 이슈 번호가 하나 이상 필요합니다.\n' >&2 - if [[ "$event_name" == "push" ]]; then + if [[ "$event_name" == "push" || "$event_name" == "pull_request" ]]; then print_pr_format >&2 fi exit 1 @@ -131,7 +212,7 @@ fi if [[ -z "$normalized_notes" ]]; then printf '::error::Work Description 섹션에는 변경 내용이 하나 이상 필요합니다.\n' >&2 - if [[ "$event_name" == "push" ]]; then + if [[ "$event_name" == "push" || "$event_name" == "pull_request" ]]; then print_pr_format >&2 fi exit 1 diff --git a/.github/scripts/render-distribution-release-notes.test.mjs b/.github/scripts/render-distribution-release-notes.test.mjs index 6f25f736..d87d8bfe 100644 --- a/.github/scripts/render-distribution-release-notes.test.mjs +++ b/.github/scripts/render-distribution-release-notes.test.mjs @@ -142,7 +142,9 @@ test("섹션은 다음 제목에서 끊긴다", async (t) => { assert.doesNotMatch(notes, /스크린샷 없음/); }); -test("굵은 글씨로 시작하는 문단의 별표를 잘라 내지 않는다", async (t) => { +test("굵은 글씨로 시작하는 문단의 글자를 잘라 내지 않는다", async (t) => { + // 목록 표시를 뗄 때 「**굵게**」 의 별표 하나를 표시로 보고 잘라 내던 자리다(#159). + // 지금은 별표를 표기로 보고 함께 벗기지만, 잘려 나가면 안 되는 것은 글자다. const body = "## 📌 Issues\n\nCloses #8\n\n## 📎 Work Description\n\n" + "**회차 식별자는 되풀이하는 일정에만 채웁니다.** 나머지는 그대로다.\n"; @@ -150,5 +152,189 @@ test("굵은 글씨로 시작하는 문단의 별표를 잘라 내지 않는다" const notes = await fs.readFile(outputPath, "utf8"); assert.equal(result.status, 0, result.stderr); - assert.match(notes, /\*\*회차 식별자는 되풀이하는 일정에만 채웁니다\.\*\*/); + assert.match(notes, /- 회차 식별자는 되풀이하는 일정에만 채웁니다\. 나머지는 그대로다\./); +}); + +test("코드 블록은 통째로 빠진다", async (t) => { + // 테스터가 앱에서 보는 것은 짧은 목록 하나다. 백틱 줄과 명령어가 항목으로 들어가면 + // 무엇이 바뀌었는지 읽기 어려워진다(#182). + const body = [ + "## 📌 Issues", + "Closes #9", + "## 📎 Work Description", + "- 알람 소리가 다시 납니다", + "```bash", + "./gradlew assembleRelease", + "```", + "- 잠금 화면에서도 뜹니다", + "~~~", + "adb logcat", + "~~~", + ].join("\n"); + const { result, outputPath } = await runRenderer(t, { eventName: "push", body }); + const notes = await fs.readFile(outputPath, "utf8"); + + assert.equal(result.status, 0, result.stderr); + assert.match(notes, /- 알람 소리가 다시 납니다/); + assert.match(notes, /- 잠금 화면에서도 뜹니다/); + assert.doesNotMatch(notes, /gradlew/); + assert.doesNotMatch(notes, /adb logcat/); + assert.doesNotMatch(notes, /```/); + assert.doesNotMatch(notes, /~~~/); +}); + +test("표와 수평선과 HTML 줄은 항목이 되지 않는다", async (t) => { + const body = [ + "## 📌 Issues", + "Closes #10", + "## 📎 Work Description", + "| 자리 | 전 | 후 |", + "|---|---|---|", + "| 공유 읽기 | 누구나 | 가족만 |", + "---", + "
자세히", + "- 가족만 내 일정을 봅니다", + "
", + ].join("\n"); + const { result, outputPath } = await runRenderer(t, { eventName: "push", body }); + const notes = await fs.readFile(outputPath, "utf8"); + + assert.equal(result.status, 0, result.stderr); + assert.equal( + notes.split("변경 내용\n")[1], + "- 가족만 내 일정을 봅니다\n", + ); +}); + +test("링크는 글자만 남고 인라인 코드 표기는 벗겨진다", async (t) => { + const body = [ + "## 📌 Issues", + "Closes #11", + "## 📎 Work Description", + "- [개인정보처리방침](https://1hyok.github.io/SlowClock/privacy.html) 을 고쳤습니다", + "- `sharedCode` 가 빈 일정도 고쳐집니다", + "- ![화면](https://example.com/shot.png) 알람 화면이 커집니다", + "> 인용으로 적은 줄도 그대로 읽힙니다", + ].join("\n"); + const { result, outputPath } = await runRenderer(t, { eventName: "push", body }); + const notes = await fs.readFile(outputPath, "utf8"); + + assert.equal(result.status, 0, result.stderr); + assert.match(notes, /- 개인정보처리방침 을 고쳤습니다/); + assert.match(notes, /- sharedCode 가 빈 일정도 고쳐집니다/); + assert.match(notes, /- 알람 화면이 커집니다/); + assert.match(notes, /- 인용으로 적은 줄도 그대로 읽힙니다/); + assert.doesNotMatch(notes, /https:/); +}); + +test("장식만 있는 줄만 남으면 배포를 막는다", async (t) => { + // 장식을 걷어 낸 뒤 아무 문장도 남지 않으면 테스터는 무엇을 확인할지 모른다. + // 걷어 내기가 검사를 무력화하면 안 된다(#182). + const body = [ + "## 📌 Issues", + "Closes #12", + "## 📎 Work Description", + "```", + "./gradlew test", + "```", + "---", + ].join("\n"); + const { result } = await runRenderer(t, { eventName: "push", body }); + + assert.equal(result.status, 1); + assert.match(result.stderr, /Work Description/); +}); + + +test("PR 검사도 배포와 같은 본문을 받아 같은 노트를 만든다", async (t) => { + const pr = await runRenderer(t, { eventName: "pull_request", body: TEMPLATE_BODY }); + const push = await runRenderer(t, { eventName: "push", body: TEMPLATE_BODY }); + assert.equal(pr.result.status, 0, pr.result.stderr); + assert.equal( + await fs.readFile(pr.outputPath, "utf8"), + await fs.readFile(push.outputPath, "utf8"), + ); +}); + +test("실제 PR 검증 스텝이 잘못된 본문을 거부하고 수정된 본문은 통과시킨다", async (t) => { + const workflow = await fs.readFile( + new URL("../workflows/repository-quality.yml", import.meta.url), "utf8", + ); + const step = workflow.split(" - name: Validate distribution release notes\n")[1] + ?.split("\n - name:")[0]; + assert.ok(step, "PR 단계에서 배포 노트 검증을 실행해야 합니다"); + assert.match(step, /if: inputs\.pull_request_number > 0/); + const eventName = step.match(/EVENT_NAME: ([^\n]+)/)?.[1]; + const command = step.split(" run: |\n")[1] + .split("\n").map((line) => line.replace(/^ {10}/, "")).join("\n"); + const directory = await fs.mkdtemp(path.join(os.tmpdir(), "slowclock-pr-notes-")); + t.after(() => fs.rm(directory, { recursive: true, force: true })); + const prJsonFile = path.join(directory, "pr.json"); + const sourceRoot = fileURLToPath(new URL("../../", import.meta.url)); + const cases = [ + ["Closes #182\n배포 오류를 고칩니다", false], + ["## Issues\nCloses #182\n## Work Description\n- \n", false], + ["## Issues\nCloses #182\n## Work Description\n```sh\necho test\n```", false], + [null, false], + [TEMPLATE_BODY, true], + [TEMPLATE_BODY + "\n$(touch injected)\n", true], + ]; + for (const [body, succeeds] of cases) { + await fs.writeFile(prJsonFile, JSON.stringify({ body })); + const result = spawnSync("bash", ["-c", command], { + cwd: sourceRoot, + encoding: "utf8", + env: { ...process.env, EVENT_NAME: eventName, PR_JSON_FILE: prJsonFile, RUNNER_TEMP: directory }, + }); + assert.equal(result.status === 0, succeeds, result.stderr); + } + await assert.rejects(fs.access(path.join(sourceRoot, "injected"))); +}); + + +test("코드 안의 제목과 종류·길이가 다른 fence는 섹션을 끊지 않는다", async (t) => { + for (const fence of ["````", "~~~~"]) { + const other = fence.startsWith("`") ? "~~~" : "```"; + const body = [ + "## Issues", "Closes #182", "## Work Description", + "- 앞의 설명", fence + "text", "# 코드 안 제목", + other, "- 코드 내부", fence.slice(1), "## 가짜 섹션", + fence + " text", "- 아직 코드", fence + fence[0], + "- 뒤의 설명", "## Screenshot", "화면 설명", + ].join("\n"); + const { result, outputPath } = await runRenderer(t, { eventName: "pull_request", body }); + assert.equal(result.status, 0, result.stderr); + assert.equal((await fs.readFile(outputPath, "utf8")).split("변경 내용\n")[1], + "- 앞의 설명\n- 뒤의 설명\n"); + } +}); + +test("코드와 주석 안의 가짜 섹션과 번호로는 검사를 통과하지 못한다", async (t) => { + const hidden = "## Issues\nCloses #999\n## Work Description\n- 가짜 설명"; + for (const body of ["```\n" + hidden + "\n```", "", + "## Issues\n```\nCloses #999\n```\n## Work Description\n- 설명", + "## Known issues\nCloses #999\n## Work Description\n- 설명"]) { + const { result } = await runRenderer(t, { eventName: "pull_request", body }); + assert.equal(result.status, 1, body); + } +}); + +test("주석 안의 fence와 제목은 뒤의 설명을 숨기지 않는다", async (t) => { + const body = "## Issues\nCloses #182\n## Work Description\n" + + "\n- 설명 나머지\n" + + "- *기울임* と _기울임_ と snake_case を표시\n"; + const { result, outputPath } = await runRenderer(t, { eventName: "push", body }); + assert.equal(result.status, 0, result.stderr); + const notes = await fs.readFile(outputPath, "utf8"); + assert.match(notes, /- 설명 나머지/); + assert.match(notes, /- 기울임 と 기울임 と snake_case を표시/); + assert.doesNotMatch(notes, /주석|가짜 제목/); +}); + +test("긴 리뷰 섹션이 있어도 추출 파이프를 중간에 닫지 않는다", async (t) => { + const body = "## Issues\nCloses #182\n## Work Description\n- 설명\n" + + "## To Reviewers\n" + "긴 보충 설명\n".repeat(10000); + const { result, outputPath } = await runRenderer(t, { eventName: "push", body }); + assert.equal(result.status, 0, result.stderr); + assert.equal((await fs.readFile(outputPath, "utf8")).split("변경 내용\n")[1], "- 설명\n"); }); diff --git a/.github/workflows/repository-quality.yml b/.github/workflows/repository-quality.yml index 957de6ae..83d5465f 100644 --- a/.github/workflows/repository-quality.yml +++ b/.github/workflows/repository-quality.yml @@ -185,6 +185,21 @@ jobs: GH_TOKEN: ${{ github.token }} run: node .github/scripts/validate-pr-issue-link.mjs "${{ steps.changed-files.outputs.pull_request_json }}" + # main 배포와 같은 렌더러로 확인한다. 본문을 고치면 edited 이벤트로 다시 검증한다. + - name: Validate distribution release notes + if: inputs.pull_request_number > 0 + env: + EVENT_NAME: pull_request + PR_JSON_FILE: ${{ steps.changed-files.outputs.pull_request_json }} + run: | + set -euo pipefail + + export RELEASE_PR_BODY_FILE="$RUNNER_TEMP/distribution-pr-body.md" + jq -er '.body | select(type == "string" and length > 0)' "$PR_JSON_FILE" \ + > "$RELEASE_PR_BODY_FILE" + bash .github/scripts/render-distribution-release-notes.sh \ + "$RUNNER_TEMP/distribution-release-notes.txt" + - name: Install actionlint env: ACTIONLINT_VERSION: 1.7.12