Skip to content
Merged
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
80 changes: 80 additions & 0 deletions .github/scatterlab/android-prebuilt.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,84 @@ configuration.resolutionStrategy.force(
| 공개 시점 | `build`가 **draft**로 만들고, `verify`가 통과한 뒤에만 공개(`--draft=false`) | draft는 push 권한자에게만 보이고 에셋 URL이 나머지에게 404다. 미검증 릴리스가 그 창에서 노출되지 않는다 |
| 캐시 | `~/.gradle/scatterlab-react-native/<fork-version>/maven` | 아래 "좌표 충돌" 참조 |

## 저장소 순서 — 소비자도 한 가지를 해야 한다

`react.internal.mavenLocalRepo` 를 세팅하면 RNGP가 Maven Central에서 `com.facebook.react` 그룹을 제외한다. **그것만으로는 부족하다.** 그 제외는 **RNGP가 추가한** 저장소에만 붙고, RNGP는 app 프로젝트의 `afterEvaluate` 에서 저장소를 추가한다(`ReactPlugin.kt:90-99`). 소비자가 자기 빌드 스크립트에 `repositories { mavenCentral() }` 을 써 두었으면 그게 목록 앞자리를 차지하고, Gradle은 저장소를 선언 순서대로 조회하므로 우리 저장소에 닿기 전에 업스트림 AAR이 나간다.

zeta에서 실측한 저장소 목록:

```
[0] Google https://dl.google.com/...
[1] MavenRepo https://repo.maven.apache.org/maven2/ ← 필터 없으면 여기서 나간다
[8] maven6 file:.../scatterlab-react-native/<version>/maven/
[9] MavenRepo2 https://repo.maven.apache.org/maven2/ ← RNGP가 추가, excludeGroup 있음
```

그래서 **소비자가 자기 저장소 선언에서 그 모듈을 제외해야 한다.** 규칙은 "`mavenCentral()` 을 제외한다" 가 아니라 **소비자가 선언한 저장소 중 이 좌표를 서빙할 수 있는 것 전부**다 — 콘텐츠 필터는 그것이 붙은 저장소에만 적용되므로, 사설 미러나 `mavenLocal()` 을 앞에 하나 더 두면 그것이 먼저 응답한다.

zeta는 `google()`·`mavenCentral()` 둘뿐이라 `packages/app/android/app/build.gradle.kts` 가 이렇게 된다(뒤따르는 광고 SDK 저장소들은 이 좌표를 서빙하지 않는다):

```kotlin
val forkOnly: (org.gradle.api.artifacts.repositories.MavenArtifactRepository) -> Unit = {
it.content { excludeModule("com.facebook.react", "react-android") }
}
google(forkOnly)
mavenCentral(forkOnly)
```
Comment thread
coderabbitai[bot] marked this conversation as resolved.

그룹 전체가 아니라 모듈 하나만 제외하는 이유는, RNGP가 `react-native` 를 `react-android` 로, `hermes-android` 를 `com.facebook.hermes` 그룹으로 치환해서 이 그룹에서 실제로 해석되는 좌표가 `react-android` 뿐이고, 그룹을 통째로 막으면 우리가 싣지 않는 것에서 깨지기 때문이다.

### 제외 범위는 릴리스가 싣는 좌표와 같이 간다

`publishAllToMavenTempLocal` 산출물은 현재 `com.facebook.react:react-android` 하나다. Hermes는 `com.facebook.hermes:hermes-android:<hermesVersion>` 으로 **다른 그룹·다른 버전**에서 해석되고(`DependencyUtils.kt` 의 `getDependencySubstitutions`) 우리는 그걸 싣지 않으므로, 소비자가 그것까지 제외하면 아무 저장소도 응답하지 못해 빌드가 깨진다.

`ReactAndroid` 의 C++ 수정은 `react-android` AAR의 `jniLibs` 로 들어가므로 이미 덮인다. 덮이지 않는 것은 **Hermes 엔진 자체**를 패치하는 경우다. 그때는 릴리스에 `hermes-android` 를 추가하고 소비자 제외 목록도 같이 늘려야 한다. 두 목록은 항상 같이 움직인다 — 한쪽만 늘리면 빌드가 깨지고, 다른 쪽만 늘리면 조용히 업스트림이 실린다.

### 이 실패는 조용하다

배선이 빠져도 **빌드는 성공하고 경고도 없다.** 아티팩트는 정확하고, 소비자만 그것을 쓰지 않는다. 이 레포의 검사는 전부 우리가 만든 AAR을 보므로 — 스모크, tarball 게이트, `verify_symbol` — 소비자 쪽 결함을 하나도 잡지 못한다. 판정 기준은 **소비자가 실제로 해석한 파일의 심볼 개수**다:

```groovy
import org.gradle.api.artifacts.component.ModuleComponentIdentifier

// 소비자 프로젝트에 init script 로 주입
def cfg = project.configurations.getByName('<variant>RuntimeClasspath')
def hits = cfg.incoming.artifactView { view ->
view.componentFilter { id ->
id instanceof ModuleComponentIdentifier &&
id.group == 'com.facebook.react' && id.module == 'react-android'
}
}.artifacts.artifacts
if (hits.size() != 1) {
throw new GradleException("expected exactly 1 react-android artifact, got ${hits.size()}")
}
println "RESOLVED ${hits.first().variant.owner} :: ${hits.first().file}"
```

`lenient = true` 를 쓰지 않는 것과 개수를 단언하는 것이 둘 다 필요하다. lenient 는 해석에 실패한 아티팩트를 **조용히 결과에서 빼므로**, 빈 결과가 "업스트림이 안 실렸다" 인지 "해석이 깨졌다" 인지 구별되지 않는다. 문자열 `contains` 대신 `ModuleComponentIdentifier` 의 `group`·`module` 로 맞추는 것도 같은 이유다.

그 AAR의 `classes.jar` 를 풀어 그 버전이 도입한 식별자를 `javap -p` 로 센다. 0이면 업스트림이 실린 것이다. 모든 fork 버전이 같은 좌표(`com.facebook.react:react-android:0.87.1`)를 쓰므로 파일 경로나 존재 여부로는 판별할 수 없다.

**Kotlin 톱레벨 함수는 파일 파사드 클래스에 들어간다.** `0.87.1-scatterlab.4` 의 `visibleTextEnd` 는 `TextDecorationStyle.class` 가 아니라 `TextDecorationStyleKt.class` 에 있다. class 파일 하나만 보면 패치가 들어 있어도 0을 센다.

**검증은 반드시 실제 소비자에서 한다.** 저장소가 미리 선언돼 있지 않은 프로브 프로젝트는 이 결함을 재현하지 못한다 — 그런 프로젝트에서는 RNGP가 추가한 저장소가 유일하므로 항상 통과한다.

### `repositoriesMode` — 기본값에서만 성립한다

RNGP는 저장소를 **프로젝트 수준**(app 프로젝트의 `afterEvaluate`)에 추가한다. `react.internal.mavenLocalRepo` 경로든 아니든 마찬가지다. 따라서 소비자의 `dependencyResolutionManagement.repositoriesMode` 가 기본값이 아니면 RNGP 메커니즘 자체가 성립하지 않는다.

| 모드 | RNGP 기본 경로 | 우리 배선 | 결과 |
| --- | --- | --- | --- |
| `PREFER_PROJECT` (기본) | 동작 | 동작 | 지원 대상. zeta가 여기다 |
| `PREFER_SETTINGS` | 프로젝트 저장소가 무시된다 | 무시된다 | settings의 Maven Central이 업스트림 AAR을 **조용히** 서빙한다 |
| `FAIL_ON_PROJECT_REPOS` | 구성 오류 | 구성 오류 | 빌드가 죽는다 |

zeta는 `settings.gradle.kts` 에 `dependencyResolutionManagement` 블록이 없어 기본값이고, 저장소를 `app/build.gradle.kts` 의 프로젝트 수준에 선언한다. 다른 모드를 쓰는 소비자가 생기면 배선을 settings 수준(`dependencyResolutionManagement.repositories`)으로 옮겨야 한다.

### 알려진 한계

fork 쪽 스크립트가 `exclusiveContent` 로 이 모듈을 우리 저장소에 잠그면 순서에 의존하지 않으므로 소비자 배선 없이도 성립한다. 다만 그것을 `project.repositories` 에 등록하면 위 표의 두 비기본 모드에서 똑같이 무시되거나 깨진다. 스크립트는 settings 평가 중에 돌므로 `dependencyResolutionManagement.repositories` 에 등록하는 편이 옳고, 그러려면 세 모드 fixture와 위 harness로 실제 소비자에서 양·음 양쪽을 재현한 뒤여야 한다.

## 좌표 충돌 — 이 설계의 핵심 함정

`VERSION_NAME`이 base로 고정되므로 **모든 fork 버전의 AAR이 같은 좌표를 갖는다**: `com.facebook.react:react-android:0.87.1`. `-scatterlab.2`의 AAR과 `-scatterlab.3`의 AAR은 Gradle이 보기에 구별 불가능한 같은 모듈이다.
Expand Down Expand Up @@ -89,6 +167,8 @@ env ORG_GRADLE_PROJECT_react.internal.useHermesStable=true \

**`SCATTERLAB_PREBUILT_BASE_URL`** — 다운로드 base URL을 덮어쓰는 환경변수. fork npm 패키지에 그대로 실려 있어 모든 소비자 빌드가 이 코드를 거친다. 오직 소비자 스크립트 스모크 테스트가 로컬 fixture 서버를 가리키기 위한 시임이라, 값의 host가 루프백(`127.0.0.1`, `[::1]`, `localhost`)일 때만 적용된다. `.sha256` 사이드카도 같은 base에서 받으므로, 루프백이 아닌 값을 그냥 받아들이면 아카이브와 그걸 검증할 체크섬을 같은 곳에서 받게 돼 무결성 검사가 아무것도 증명하지 못한다 — 그래서 루프백이 아니면 실제 릴리스로 폴백하지 않고 **빌드를 중단한다.**

스크립트는 `gradle.beforeProject` 에서 프로퍼티를 세팅한다. 그것만으로는 소비자가 이 AAR을 실제로 쓰게 되지 않는다 — 위 "저장소 순서" 참조.

그러면 RNGP가(`DependencyUtils.kt:56-87`):

- 그 디렉터리를 maven 저장소로 추가하고,
Expand Down