From ce3caa8c1747ac7a4333de4861b2e7b1ea090b9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Tue, 25 Aug 2026 03:37:02 +0900 Subject: [PATCH] =?UTF-8?q?fix(ci):=20Android=20Lint=20=EB=A6=AC=ED=8F=AC?= =?UTF-8?q?=ED=84=B0=EB=A5=BC=20job=20summary=20=EB=A1=9C=20=EA=B5=90?= =?UTF-8?q?=EC=B2=B4=20=E2=80=94=20=EB=A7=A4=20run=20=EB=84=A4=ED=8A=B8?= =?UTF-8?q?=EC=9B=8C=ED=81=AC=20=EC=84=A4=EC=B9=98=ED=95=98=EB=8D=98=20?= =?UTF-8?q?=EB=8F=84=EC=BB=A4=20=EC=95=A1=EC=85=98=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Report Android Lint (Reviewdog)` 는 lint 위반이 0건인 PR 에서도 job 을 red 로 만들었다. `Check Project Issues (Android Lint)` 가 develop ruleset 의 required check 라 그대로 머지 차단이 된다. 원인은 dvdandroid/action-android-lint 가 도커 액션이고 그 Dockerfile 이 매 run 마다 `wget -O - -q …/install.sh | sh -s` 로 reviewdog 을 받아 설치한다는 것이다. 파이프라인의 exit status 는 마지막 sh 의 것이라 wget 이 실패해도 sh 는 빈 입력을 받고 0 을 낸다. `-q` 라 경고도 없다. 그래서 이미지 빌드는 성공하고 몇 분 뒤 entrypoint 에서 `reviewdog: not found` 로 죽는다. 빈도는 리포터 스텝이 존재한 전 기간 1173 run 중 단독 실패 2건이다. 낮지만 raw.githubusercontent.com 이 흔들리면 그 시간대 모든 PR 이 동시에 red 가 되는 상관 실패다. 반면 산출은 없다 — 최근 PR 12건에서 인라인 코멘트 0건. 재시도로는 못 푼다. 도커 액션 이미지는 job 시작 시 한 번만 빌드되고, reviewdog 부재는 그 빌드 시점에 확정된다. 같은 job 안에서 스텝을 다시 돌려도 같은 이미지가 뜬다. `Merge module lint XMLs into one` 도 같이 지웠다. 모듈별 XML 을 한 파일로 합치던 유일한 소비자가 reviewdog 이었다. 새 스크립트가 모듈별 XML 을 직접 읽으므로 셸+xmllint 로 XML 을 문자열 조작하는 단계가 사라지고, 합치면서 잃던 위반의 모듈 귀속(`:core:common`)이 오히려 보존된다. 파서는 실물로 맞췄다. lint 9.2.1 은 여는 태그를 여러 줄에 걸쳐 쓰고 message 의 닫는 꺾쇠를 이스케이프하지 않는다(`<꺾쇠> 포함`). `[^>]*` 로 태그를 끊으면 거기서 잘려 뒤따르는 위반을 통째로 잃으므로, 따옴표 안팎을 세어 태그 끝을 찾는다. 인라인 코멘트를 job summary 로 옮기는 판단은 ktlint 쪽 선례와 같다 (#1012). 그 결정이 lint-workflow-policy.test.mjs 에 "Android Lint 는 인라인 유지" 로 박혀 있었으므로, #279 회귀 방지라는 의도는 남기고 수단만 바꾸도록 함께 갱신했다. --- .github/scripts/lint-workflow-policy.test.mjs | 20 +- .../scripts/render-android-lint-summary.mjs | Bin 0 -> 9707 bytes .../render-android-lint-summary.test.mjs | 214 ++++++++++++++++++ .github/workflows/lint.yml | 39 +--- 4 files changed, 240 insertions(+), 33 deletions(-) create mode 100644 .github/scripts/render-android-lint-summary.mjs create mode 100644 .github/scripts/render-android-lint-summary.test.mjs diff --git a/.github/scripts/lint-workflow-policy.test.mjs b/.github/scripts/lint-workflow-policy.test.mjs index 23b28d50d0..ebb899da66 100644 --- a/.github/scripts/lint-workflow-policy.test.mjs +++ b/.github/scripts/lint-workflow-policy.test.mjs @@ -31,9 +31,19 @@ test("ktlint violations are rendered into the job summary even when the check fa assert.match(lintWorkflow, summaryStep); }); -test("Android Lint keeps its inline reporting — it reuses the XML instead of re-running", () => { - // #279 가 푼 문제(모듈별 위반이 수만 줄 로그에 묻힘)를 되돌리지 않는다. lintDebug 가 - // 만든 XML 을 재활용하므로 검사가 두 번 돌지 않는다. - assert.match(lintWorkflow, /lint_xml_file:\s*app\/build\/reports\/lint-results-merged\.xml/); - assert.match(lintWorkflow, /\.\/gradlew lintDebug/); +test("Android Lint runs exactly once per pull request", () => { + const gradleInvocations = lintWorkflow.match(/^\s*run:.*lintDebug/gm) ?? []; + assert.equal(gradleInvocations.length, 1); + assert.match(lintWorkflow, /\.\/gradlew lintDebug[^\n]*--continue/); +}); + +test("Android Lint reports through the job summary, not a Docker reviewdog action", () => { + // #279 가 푼 문제(모듈별 위반이 수만 줄 로그에 묻힘)를 되돌리지 않는다 — 자리만 PR + // 인라인 코멘트에서 job summary 로 옮겼다. 그 도커 액션은 매 run 마다 reviewdog 을 + // 네트워크에서 받아 설치했고, 설치가 조용히 실패하면 lint 위반이 0건이어도 required + // check 가 red 가 됐다. 이미지는 job 당 한 번만 빌드돼 재시도로도 풀리지 않는다 (#1093). + assert.match(lintWorkflow, /render-android-lint-summary\.mjs/); + assert.match(lintWorkflow, /- name: Summarize Android Lint violations\n\s+if: always\(\)/); + // 주석은 이력이라 남기고, 실제로 액션을 부르는 uses 줄만 금지한다. + assert.doesNotMatch(lintWorkflow, /^\s*uses:\s*dvdandroid\/action-android-lint/m); }); diff --git a/.github/scripts/render-android-lint-summary.mjs b/.github/scripts/render-android-lint-summary.mjs new file mode 100644 index 0000000000000000000000000000000000000000..e8f73bb65964b790df30b59c8287734b393e4c83 GIT binary patch literal 9707 zcmcgyZEqCU74Bz##brQb7wlP(`k^wmkpoU*1qWoCB$e&3}&bjCPoO@>X?JYF} zzcgpNCGMVQu23%lO6EDII2M!R=AJ_qUUsXiP7_v z_lE88GZQCSD+}869YF3WmDtFer&(v?GK+p(k8WR!9$t%Xtg-0IdS`1Rx_*Z_wi~kO z`$yZKJ=(s$Ws2t$e6Be!ksjWRZm$qr=gG&>!<#I6x&?f5PsMYCkWGx9J~c6Q_MMUO zQ8vU3qSf#7py`A`f0c-s7wW_Z*pSDA%!}xOAXwv8*z|eHs)gKly^uozJm79!TP(iYj_xlj648~-XmyRC9$v=Ib?}*CZhYkZv*V{uoH;pq z_QdGvskgx4Z;vX*C*K}BeR}l7*%Mej*P#~;*UStzPkc}qI=I- zywZ*~mRY>oj=uR*e6P)-pTEXdUIN%>Ia=SsBGGtL9L3@n%kd|7Sp4%!wEA8A)h!m^ zCtGS;khmwuCZ{0PyJM$Lj!cc6dWVEMa`-SDq1JROLa$X`fxo?9cM9h%r^$<7L!0@gB`!0-#GiKafL9BX1fE+MjomNH*RtQ1onjtSF--15oTJ=OB(!uLiSXoe> zF~R3YFEk3}nVGr$O9a{)G78fl7_$e9M!9J63%rt{h66Ktm2{4=RQqCO`p9gN4KTOq zID$XKiHt{BJ-YFeh`WFO5`VfHeFx8hmt0?qpI^q8?K|!5yURJJVJ|P=VRaq^);wqA z9_0hjaYvvJGYap!>tz-{S&qJW&U|Z;m50kR#&561Uv9?B>%y79Lj2VuI(hr!6+|4J zT%P`5c=lkKA?#T6(e-%cdGrIK^YI39NZek5BQ7I@M1Q{wfIb%8*@%}nw?D`F-D}%7 z|3Xe4x3_QxiyyY*hbxLQNZk{A8^7QL4PIHWe5-O3ix>?n3G1 z!Tn2sXqDw5WYJ2~4`5woQgPzq0*W3hsGY2NvwTa!L1>}e3Uiu*{Zq999oDA^t4Jk% zk#!X~?AD+25878Erd~&jsjaBYeY=h_3MuR$Xz~CdCIU!4$_R>=*W!CynehFr9X+^* zc*pWhhDyOYTc~@tsA!{HcAmBYPNA-AFC9uBDc+pdbgNWuq{B+~4>6KZd3=}qCJ5xg zz~!(Iud)LN*xu6gOi&u0Etw$?!h!}YW_2nK)&}WuSn3;!#n5(}JY7w-CE4e0l`IRx zWaGUD;Vv@?yq-p)3`PlQgS|g~Qe;JuQ%Rli+UK2(wy2%ozEEk5>hkKRohRsRuB@v> zL@5xtt@G?AmEeark$Ye097SU3&nx^vZ7(IkOn5@HzT6UI$uFcuX+UJIz_BY_d1lVE zeGIt8JaWLD+HuDVP{;-LG^whe9hqfA(3ep%Qt+0vOD=wsV#F`VK2>)P4a*PsNwj#> zJcz8k#AIg#0}>WG&>^<7v?M8rNK!tM zOC*NaL7O4dK$n9kG>A;RWyuohIiFkSdU~byZGmr+?hY}ew8UWKUMubR*)rA0_{mR* zb;qj+5fSYOYwF#|Wo~a$P0JMYr1;z~_c;1yIgsTk@^XbhGF_AvCAh@4R+F(9$$yecIbMMQ)i zJCde@eP7c6C4^&TA+{AV2z5_P{x)!*i9HMXxX3v zLZb#&l46WJ@awJU=gVwlWCE!Pw+V5ni0>i8{dFVy{*fphPgmn-t1P~T?XNx&u2?-^ zwcM)j*;TY=58|uKgbUWrbDugM^1aRmZX+Ob{}#wl>FPY*idHY@{E@UKMA0n})ec&K zQ&pU1l?R3L{w2DcngMUL%z1Ht$*feY$^s8E3+0_9#&dTRjzj@zPB9zJV4<*-?2w9N zPD+y;t=ZK9mg-izWb%y8->|Fbc#a)oM$NKk*-t^hGcnV}}u#=@lgbp&$dQJmzq* ztDICkr&)LZOS%%d2j!OX=UnMY?W%7>pWuQ(2QH31jL`qRFM%R}LB!hUyo@nnW2Kg3;iW0|rc29@xK>P>{*>a34#d zi{^)(?G^xBr1lx|#$!zG^4T77d3q60vZRc{R;$Rth35imU)K?cjtK zxv(Q^5iBTnWiDDxkEJJ~C&|$qLH1?J6S~+ZXvw2Ah;~F_>5THziIp-O%4KFcm#H2^ zkv=;OA=g=I2CGuRJfR>d_8>tB>NuH*cZKY28+{-ZG>rS?h>*$;1$8=g=7Hvd6=aN5q|0_{GgGwj!F%w@f?*o}lP8G4VZ&%|dKh2E zgorhfU%CySvQ$(dNWFfSu~NmzZ>lrN(3UB%dt{o=OWM@9jE4nmy(+=Ho3#Et@=KcQ zRf-{VWkOOOMlmV5+%0Kkb(YeU?r553?$VOWFO*7@Yoh=*!`kl*Xi#5i22>ldqdK&T zN-0v4DrKi9=Z7DvUf6%s6@#j_W;yAj=w8-+Nl}E2g1l@{4~OwU(U6%;9Q0ZCCq}wh>ov1|-!d7{AGO z^Te|<%o6(46bbhw;+&~VI>EIv({vGqn0VHxb+P9c(7AvW@l8sAJV3kZ0ohAD*;SLS z77jn|)e#)k*M9HmIvoBLhGr^$}rlAHiiYC0Ro=>X@Z69L@?oZ11N~!K~aam%N zxheF0kBYTfS*#Y2$7K3EE*yw@$oyy~XTd;SV?`U%%ZfQ#NL7cEcpy_BW7EdIeQZQs zETI7Lf(-qKfh(kZtte)1;u4z7c4>2>aD>LPnD_4`3Zz7knf_f*pe<>qj3qDgUXAjUBS4b^` zn_>?mQ}o727l|hZv!)2jL_OWnnQRcNV$)OuG`e}h1$sqh4gyU`4}Qe856CfX9|DtS_h(8X0!3N6PG zBd4V9NJ_cmh+i>VOcb-z9n-DMCA$0)KF zS3}fHvm}~8s;7bvop;E~gmz>%RKCP!P)%jSh)OzB26_FQRMO0vC_gQBshlg68Hx}M z)37QJE!zo(2i1G;VWm;oxD1x`G6Cztm*G8e0pefTAi3Q3e5svlwlnM`RwlvA{X*J= zx^gg*nzXQ^F-rkO!_>lbYG(lic9|W@og>Z?egY3lOF&9a7lRfn(81}3FhMcnDLFM$ ziq3f?%4BbCWZI96GRQV%j0s`kJemI9WXR!#82Xn97y_=eliwa(U)#QlcT^APm9R+A z^0x=CaE+4o4Q+Q-3MHR%T0uEnbRJ#fg}Q}VhIVC{54uVneo|w;IX3mynb*(0cWUD8 z$&d2;&fP9M>3qG%DQKT5sNy&Zx4ZF#}nE(I) literal 0 HcmV?d00001 diff --git a/.github/scripts/render-android-lint-summary.test.mjs b/.github/scripts/render-android-lint-summary.test.mjs new file mode 100644 index 0000000000..f5ccc88d03 --- /dev/null +++ b/.github/scripts/render-android-lint-summary.test.mjs @@ -0,0 +1,214 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { + collectReportFiles, + collectViolations, + moduleOf, + parseLintXml, + renderSummary, +} from "./render-android-lint-summary.mjs"; + +// lint 9.2.1 이 실제로 남긴 리포트를 그대로 옮겼다. 여는 태그가 여러 줄이고, +// message 의 닫는 꺾쇠는 이스케이프되지 않는다 (`<꺾쇠> 포함`). +const REPORT = ` + + + + + + + + + + +`; + +const EMPTY_REPORT = ` + + +`; + +async function writeReport(root, modulePath, xml) { + const report = path.join(root, modulePath, "build", "reports", "lint-results-debug.xml"); + await fs.mkdir(path.dirname(report), { recursive: true }); + await fs.writeFile(report, xml); + return report; +} + +test("parses a multi-line issue tag, decodes entities, and keeps a raw closing bracket", () => { + const violations = parseLintXml(REPORT, { module: ":core:common" }); + + assert.equal(violations.length, 2); + assert.deepEqual(violations[0], { + module: ":core:common", + id: "SimpleDateFormat", + severity: "Warning", + message: + "To get local formatting use `getDateInstance()`, or use `new SimpleDateFormat(String template, Locale locale)`.", + file: "/workspace/core/common/src/main/kotlin/Probe.kt", + line: 7, + column: 38, + }); + // `[^>]*` 로 여는 태그를 끊으면 여기서 잘려 두 번째 위반을 통째로 잃는다. + assert.equal(violations[1].id, "HardcodedText"); + assert.equal(violations[1].message, 'Hardcoded string "하드코딩 <꺾쇠> 포함", should use `@string` resource'); + assert.equal(violations[1].line, 9); +}); + +test("reports no violations for a clean report", () => { + assert.deepEqual(parseLintXml(EMPTY_REPORT), []); +}); + +test("reads what it can from a report lint truncated mid-issue", () => { + const truncated = REPORT.slice(0, REPORT.indexOf('id="HardcodedText"')); + + const violations = parseLintXml(truncated); + + assert.equal(violations.length, 1); + assert.equal(violations[0].id, "SimpleDateFormat"); +}); + +test("keeps an issue that carries no location", () => { + const withoutLocation = ` + +`; + + const violations = parseLintXml(withoutLocation, { module: ":app" }); + + assert.equal(violations.length, 1); + assert.equal(violations[0].file, null); + assert.equal(violations[0].line, 0); +}); + +test("drops violations that live in the dependency cache", () => { + const cached = ` + + + +`; + + assert.deepEqual(parseLintXml(cached), []); +}); + +test("derives the Gradle module path from the report location", () => { + assert.equal(moduleOf("/workspace", "/workspace/core/common/build/reports/lint-results-debug.xml"), ":core:common"); + assert.equal( + moduleOf("/workspace", "/workspace/feature/afternote/presentation/build/reports/lint-results-debug.xml"), + ":feature:afternote:presentation", + ); +}); + +test("collects one report per module across core and feature trees", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "android-lint-summary-")); + try { + await writeReport(root, "core/common", REPORT); + await writeReport(root, path.join("feature", "afternote", "presentation"), REPORT); + await writeReport(root, "app", EMPTY_REPORT); + // lint 의 다른 리포트와 소스 트리의 XML 은 입력이 아니다. + const noise = path.join(root, "core/ui/build/reports/lint-results-release.xml"); + await fs.mkdir(path.dirname(noise), { recursive: true }); + await fs.writeFile(noise, REPORT); + const source = path.join(root, "core/ui/src/main/res/layout"); + await fs.mkdir(source, { recursive: true }); + await fs.writeFile(path.join(source, "view.xml"), REPORT); + + const reports = await collectReportFiles(root); + assert.equal(reports.length, 3); + + const violations = await collectViolations(root); + assert.equal(violations.length, 4); + // #279 회귀 가드 — :app 밖의 모듈 위반이 귀속과 함께 살아 있어야 한다. + assert.deepEqual( + [...new Set(violations.map((violation) => violation.module))].sort(), + [":core:common", ":feature:afternote:presentation"], + ); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } +}); + +test("renders a rule tally with severity and repository relative positions", () => { + const summary = renderSummary(parseLintXml(REPORT, { module: ":core:common" }), { + root: "/workspace", + reportCount: 1, + }); + + assert.match(summary, /❌ \*\*위반 2건\*\* — 모듈 1개, 규칙 2종/); + assert.match(summary, /\| `HardcodedText` \| Warning \| 1 \|/); + assert.match(summary, /- `:core:common` `core\/common\/src\/main\/kotlin\/Probe\.kt:7:38`/); + assert.doesNotMatch(summary, /\/workspace\/core/); +}); + +test("renders a pass line when every module reported without violations", () => { + assert.match(renderSummary([], { root: "/workspace", reportCount: 26 }), /✅ 위반 없음 \(모듈 26개\)/); +}); + +test("distinguishes a clean run from a run that produced no report at all", () => { + assert.match(renderSummary([], { root: "/workspace", reportCount: 0 }), /리포트가 없다/); +}); + +test("collapses a multi-line message onto one list line", () => { + const summary = renderSummary( + [ + { + module: ":app", + id: "Recycle", + severity: "Error", + message: "첫 줄\n\n둘째 줄", + file: "/workspace/app/src/main/kotlin/Probe.kt", + line: 3, + column: 0, + }, + ], + { root: "/workspace", reportCount: 1 }, + ); + + assert.match(summary, /- `:app` `app\/src\/main\/kotlin\/Probe\.kt:3` — 첫 줄 둘째 줄 \(`Recycle`\)/); +}); + +test("truncates the listing but keeps the total and the tally exact", () => { + const many = Array.from({ length: 150 }, (unused, index) => ({ + module: ":core:common", + id: "HardcodedText", + severity: "Warning", + message: "Hardcoded string", + file: "/workspace/core/common/src/main/res/layout/probe.xml", + line: index + 1, + column: 9, + })); + + const summary = renderSummary(many, { root: "/workspace", reportCount: 1 }); + + assert.match(summary, /❌ \*\*위반 150건\*\*/); + assert.match(summary, /\| `HardcodedText` \| Warning \| 150 \|/); + assert.match(summary, /외 50건/); + assert.equal(summary.split("\n").filter((line) => line.startsWith("- `")).length, 100); +}); diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 7337f1548d..f947fa816f 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -80,32 +80,15 @@ jobs: - name: Run Android Lint run: ./gradlew lintDebug --continue --parallel --build-cache - # 모듈별 lint XML 을 한 파일로 합쳐 Reviewdog 가 :feature/* · :core/* 위반도 - # PR 인라인 코멘트로 띄우도록 한다. 이전엔 :app XML 한 개만 전달돼 다른 모듈 위반이 - # Actions step 로그(수만 줄)에 묻혔다. - - name: Merge module lint XMLs into one - if: always() - run: | - set -e - out=app/build/reports/lint-results-merged.xml - mkdir -p app/build/reports - { - echo '' - echo '' - find . -path '*/build/reports/lint-results-debug.xml' -print | while read -r xml; do - xmllint --xpath '//issue' "$xml" 2>/dev/null || true - done - echo '' - } > "$out" - modules=$(find . -path '*/build/reports/lint-results-debug.xml' | wc -l) - issues=$(grep -c '