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
78 changes: 78 additions & 0 deletions .github/actions/setup-android/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
name: Setup Android
description: JDK / Gradle 설정과 빌드에 필요한 시크릿 파일을 준비한다.

# 어느 잡이든 Gradle을 돌리려면 local.properties 와 keystore, google-services.json 이 있어야 한다.
# 잡마다 같은 스텝을 복붙하면 시크릿이 하나 늘 때 전부 고쳐야 하므로 여기 한 곳에 모은다.
inputs:
signed-store-password:
required: true
signed-key-alias:
required: true
signed-key-password:
required: true
debug-base-url:
required: true
release-base-url:
required: true
google-web-client-id:
required: true
kakao-native-app-key:
required: true
kakao-native-app-key-debug:
required: true
mixpanel-token:
required: true
keystore-base64:
required: true
google-services-json-base64:
required: true

runs:
using: composite
steps:
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'

- name: Create local.properties
shell: bash
env:
SIGNED_STORE_PASSWORD: ${{ inputs.signed-store-password }}
SIGNED_KEY_ALIAS: ${{ inputs.signed-key-alias }}
SIGNED_KEY_PASSWORD: ${{ inputs.signed-key-password }}
DEBUG_BASE_URL: ${{ inputs.debug-base-url }}
RELEASE_BASE_URL: ${{ inputs.release-base-url }}
GOOGLE_WEB_CLIENT_ID: ${{ inputs.google-web-client-id }}
KAKAO_NATIVE_APP_KEY: ${{ inputs.kakao-native-app-key }}
KAKAO_NATIVE_APP_KEY_DEBUG: ${{ inputs.kakao-native-app-key-debug }}
MIXPANEL_TOKEN: ${{ inputs.mixpanel-token }}
run: |
{
echo "signed.store.file=keystore.jks"
echo "signed.store.password=$SIGNED_STORE_PASSWORD"
echo "signed.key.alias=$SIGNED_KEY_ALIAS"
echo "signed.key.password=$SIGNED_KEY_PASSWORD"
echo "debug.base.url=$DEBUG_BASE_URL"
echo "release.base.url=$RELEASE_BASE_URL"
echo "google.webClientId=$GOOGLE_WEB_CLIENT_ID"
echo "kakao.nativeAppKey=$KAKAO_NATIVE_APP_KEY"
echo "kakao.nativeAppKeyDebug=$KAKAO_NATIVE_APP_KEY_DEBUG"
echo "mixpanel.token=$MIXPANEL_TOKEN"
} >> local.properties

- name: Decode Keystore
shell: bash
env:
KEYSTORE_BASE64: ${{ inputs.keystore-base64 }}
run: echo "$KEYSTORE_BASE64" | base64 --decode > app/keystore.jks

- name: Decode google-services.json
shell: bash
env:
GOOGLE_SERVICES_JSON_BASE64: ${{ inputs.google-services-json-base64 }}
run: echo "$GOOGLE_SERVICES_JSON_BASE64" | base64 --decode > app/google-services.json

- name: Configure Gradle
uses: gradle/actions/setup-gradle@v3
115 changes: 115 additions & 0 deletions .github/scripts/build-screenshot-comment.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
#!/usr/bin/env python3
"""스크린샷 diff 리포트 PR 코멘트 본문을 만든다 (#153).

Roborazzi는 렌더가 골든과 다를 때만 `*_compare.png` 를 남긴다. 따라서 그 목록이 비어
있다는 것은 이 PR이 화면을 아무것도 바꾸지 않았다는 뜻이다.

여기서 다루는 파일 목록은 PR이 올린 코드가 만들어낸 산출물이므로 신뢰할 수 없는 입력으로
취급한다. roborazzi 출력 경로 밖이거나 경로 이스케이프가 섞인 것은 커밋도 링크도 하지 않는다.

사용법:
build-screenshot-comment.py <diff-root> <repo> <branch> <out-comment> <out-filelist>
"""

from __future__ import annotations

import sys
from pathlib import Path, PurePosixPath
from urllib.parse import quote

# 코멘트 본문은 65536자를 넘으면 API가 거부한다. 스크린샷 id가 긴 편이라 여유를 둔다.
MAX_ROWS = 50
# 이름이 길면 표 칸을 넘치므로 이 길이마다 줄을 바꾼다.
WRAP_EVERY = 28
# 모든 스크린샷 id가 이 접두사로 시작한다. 표에서는 잡음이라 떼어낸다.
ID_PREFIX = "com.sseotdabwa.buyornot."
ID_SUFFIX = "_compare.png"


def collect(diff_root: Path) -> list[PurePosixPath]:
found: list[PurePosixPath] = []
for path in diff_root.rglob("*_compare.png"):
rel = PurePosixPath(path.relative_to(diff_root).as_posix())
# roborazzi가 쓴 자리에서 나온 것만 받는다.
if "build/outputs/roborazzi" not in str(rel):
continue
# 경로 이스케이프는 거부한다. 심볼릭 링크도 따라가지 않는다.
if ".." in rel.parts or path.is_symlink():
continue
# 개행이 든 경로는 로보라찌가 만들 수 없다. 프리뷰 이름(@Preview(name = ...))이
# 스크린샷 id 에 들어가므로 여기로 개행을 흘려보낼 수 있고, 그러면 git 이 한 경로를
# 여러 pathspec 으로 쪼개 뒤쪽 줄이 pathspec magic 으로 해석된다.
if "\n" in str(rel) or "\r" in str(rel) or "\0" in str(rel):
continue
found.append(rel)
# rglob 순서는 파일시스템에 따라 달라진다. 잘린 리포트가 실행마다 다른 행을 남기지
# 않도록 정렬한다.
return sorted(found)


def display_name(file_name: str) -> str:
"""표에 쓸 짧은 이름. 스크린샷 id는 FQN 전체라 그대로 두면 표를 못 읽는다."""
name = file_name.removeprefix(ID_PREFIX).removesuffix(ID_SUFFIX)
return "<br>".join(name[i : i + WRAP_EVERY] for i in range(0, len(name), WRAP_EVERY))


def module_of(rel: PurePosixPath) -> str:
"""경로 앞부분이 모듈이다. core/designsystem/build/outputs/... -> core:designsystem"""
parts = str(rel).split("/build/outputs/")[0].split("/")
return ":".join(parts)


def build_comment(files: list[PurePosixPath], repo: str, branch: str) -> str:
lines = ["스크린샷 비교 리포트", ""]

if not files:
lines.append("이 PR은 프리뷰 렌더를 바꾸지 않았습니다.")
return "\n".join(lines) + "\n"

lines.append(f"프리뷰 **{len(files)}개**의 렌더가 base 와 다릅니다. 의도한 변경인지 확인해주세요.")
lines.append("")
lines.append("| 모듈 | 프리뷰 | Reference / Diff / New |")
lines.append("|---|---|---|")

tree = f"https://github.com/{repo}/tree/{quote(branch)}"
for rel in files[:MAX_ROWS]:
# 스크린샷 id에 한글이 섞이므로 URL은 퍼센트 인코딩한다.
url = f"https://github.com/{repo}/blob/{quote(branch)}/{quote(str(rel))}"
lines.append(
f"| `{module_of(rel)}` | [{display_name(rel.name)}]({url}) | ![]({url}?raw=true) |"
)

if len(files) > MAX_ROWS:
lines.append("")
lines.append(
f"{len(files)}개 중 {MAX_ROWS}개만 표시했습니다. "
f"전체는 [컴패니언 브랜치]({tree})에 있습니다."
)

return "\n".join(lines) + "\n"


def main() -> int:
if len(sys.argv) != 6:
print(__doc__, file=sys.stderr)
return 2

diff_root, repo, branch, out_comment, out_filelist = (
Path(sys.argv[1]),
sys.argv[2],
sys.argv[3],
Path(sys.argv[4]),
Path(sys.argv[5]),
)

files = collect(diff_root) if diff_root.is_dir() else []
# git add --pathspec-file-nul 이 읽는 형식. 개행 구분이면 한 경로가 쪼개질 수 있다.
out_filelist.write_bytes(b"".join(f"{f}\0".encode() for f in files))
out_comment.write_text(build_comment(files, repo, branch), encoding="utf-8")

print(f"diff {len(files)}건")
return 0


if __name__ == "__main__":
sys.exit(main())
98 changes: 98 additions & 0 deletions .github/scripts/check-preview-network-urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
#!/usr/bin/env python3
"""@Preview 함수 본문에 네트워크 URL이 들어가는 것을 막는다 (#153).

프리뷰가 외부 URL을 때리면 스크린샷 비교 CI의 렌더 결과가 네트워크 상태에 좌우된다.
아무도 UI를 건드리지 않은 PR에서 diff가 떠도 원인이 코드가 아니라서 추적하기 어렵다.
프리뷰는 PreviewImages의 로컬 리소스 URI만 써야 한다.

의도적으로 좁게 잡는다. `@Preview`가 붙은 함수의 본문만 보므로, 프로덕션 코드의
실제 서비스 URL(WebViewNavigation의 약관 링크 등)은 대상이 아니다.
"""

from __future__ import annotations

import re
import sys
from pathlib import Path

PREVIEW_ANNOTATION = re.compile(r"^\s*@Preview\b")
FUN_START = re.compile(r"^\s*(?:private\s+|internal\s+|public\s+)?fun\s")
NETWORK_URL = re.compile(r'"https?://')

# 스크린샷 테스트를 적용하지 않는 경로.
EXCLUDED_DIRS = ("/build/", "/src/test/", "/src/androidTest/")


def preview_function_bodies(lines: list[str]):
"""(시작줄번호, 본문 줄들) 을 @Preview 함수마다 내놓는다.

중괄호 깊이로 본문 끝을 찾는다. 프리뷰는 어노테이션과 본문이 붙어 있는 단순한
형태라 파서를 세울 필요는 없다.
"""
i = 0
n = len(lines)
while i < n:
if not PREVIEW_ANNOTATION.match(lines[i]):
i += 1
continue

# @Preview 뒤에 다른 어노테이션이나 @Composable 이 이어질 수 있다.
j = i
while j < n and not FUN_START.match(lines[j]):
j += 1
if j >= n:
return

start = j
depth = 0
opened = False
while j < n:
depth += lines[j].count("{") - lines[j].count("}")
if "{" in lines[j]:
opened = True
if opened and depth <= 0:
break
j += 1

yield start, lines[start : j + 1]
i = j + 1


def main() -> int:
root = Path(__file__).resolve().parents[2]
violations: list[str] = []

for path in root.rglob("*.kt"):
rel = "/" + str(path.relative_to(root))
if any(part in rel for part in EXCLUDED_DIRS):
continue

lines = path.read_text(encoding="utf-8").splitlines()
if not any(PREVIEW_ANNOTATION.match(line) for line in lines):
continue

for start, body in preview_function_bodies(lines):
for offset, line in enumerate(body):
if NETWORK_URL.search(line):
violations.append(
f"{rel.lstrip('/')}:{start + offset + 1}: {line.strip()}"
)

if violations:
print("@Preview 본문에 네트워크 URL이 있습니다:", file=sys.stderr)
for v in violations:
print(f" {v}", file=sys.stderr)
print(
"\n프리뷰는 PreviewImages의 로컬 리소스만 사용해야 합니다.\n"
"네트워크 이미지는 렌더 결과를 네트워크 상태에 좌우되게 만들어,\n"
"UI를 건드리지 않은 PR에서도 스크린샷 diff를 만듭니다.",
file=sys.stderr,
)
return 1

print("@Preview 본문에 네트워크 URL 없음")
return 0


if __name__ == "__main__":
sys.exit(main())
Loading
Loading