diff --git a/.github/scripts/build-cache-policy.test.mjs b/.github/scripts/build-cache-policy.test.mjs new file mode 100644 index 0000000000..3f98cd5a51 --- /dev/null +++ b/.github/scripts/build-cache-policy.test.mjs @@ -0,0 +1,98 @@ +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); + +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 withoutComments(source) { + return source + .split("\n") + .filter((line) => !/^\s*#/.test(line)) + .join("\n"); +} + +// Actions 캐시 엔트리는 (키, ref) 로 격리되고, 읽기는 «현재 ref → base → default branch» +// 순으로 매칭된다. 그래서 경쟁이 일어나는 범위는 develop 스코프 하나다 — release·canary +// 워크플로는 main 스코프에 쓰므로 develop PR 이 받는 엔트리를 가리지 않는다. +function runsOnDevelopPush(source) { + return /^\s{2}push:\n\s{4}branches:\s*\[[^\]]*\bdevelop\b/m.test(source); +} + +function runsOnPullRequests(source) { + return /^on:\n(?:[^\n]*\n)*?\s{2}pull_request:/m.test(source); +} + +test("exactly one workflow may write the shared Gradle User Home cache", async () => { + // Actions 캐시는 같은 키를 덮어쓰지 못한다. writer 가 둘이면 먼저 끝난 쪽이 키를 + // 차지하고, build cache 를 채운 쪽의 저장이 조용히 버려진다 (#996). + const writers = (await workflows()) + .filter(([, source]) => runsOnDevelopPush(source)) + .filter(([, source]) => /cache-read-only:\s*false/.test(withoutComments(source))) + .map(([name]) => name); + + assert.deepEqual(writers, ["build-cache-warm.yml"]); +}); + +test("every other Gradle workflow reads the cache without writing to it", async () => { + for (const [name, source] of await workflows()) { + if (name === "build-cache-warm.yml" || !/uses:\s*gradle\/actions\/setup-gradle@/.test(source)) { + continue; + } + if (!runsOnPullRequests(source) && !runsOnDevelopPush(source)) { + continue; + } + assert.match(source, /cache-read-only:\s*true/, `${name} must not write to the shared cache`); + // 조건식으로 쓰면 pull_request 가 아닌 trigger 에서 writer 로 돌변한다. + assert.doesNotMatch( + source, + /cache-read-only:\s*\$\{\{/, + `${name} must not decide cache writability from the event`, + ); + } +}); + +test("the warming workflow runs on develop pushes and is never cancelled mid-run", async () => { + const source = await readFile(new URL("build-cache-warm.yml", workflowDirectory), "utf8"); + + assert.match(source, /push:\n\s+branches:\s*\[develop\]/); + // 캐시는 job 의 post 스텝에서 저장된다. 중도 취소되면 그 스텝에 닿지 못한다. + assert.match(source, /cancel-in-progress:\s*false/); + assert.match(source, /--build-cache/); +}); + +test("the warming workflow covers the tasks pull request jobs actually run", async () => { + const warm = await readFile(new URL("build-cache-warm.yml", workflowDirectory), "utf8"); + const lint = await readFile(new URL("lint.yml", workflowDirectory), "utf8"); + const unitTest = await readFile(new URL("unit-test.yml", workflowDirectory), "utf8"); + + for (const task of ["ktlintCheck", "lintDebug"]) { + assert.match(lint, new RegExp(`\\./gradlew[^\\n]*${task}`), `lint.yml no longer runs ${task}`); + assert.match(warm, new RegExp(`\\b${task}\\b`), `warming misses ${task}`); + } + for (const task of [":koverXmlReportCi", ":konsist:test", ":app:compileDebugAndroidTestKotlin"]) { + assert.ok(unitTest.includes(task), `unit-test.yml no longer runs ${task}`); + assert.ok(warm.includes(task), `warming misses ${task}`); + } +}); + +test("CodeQL stays out of the build cache in both directions", async () => { + const source = await readFile(new URL("codeql.yml", workflowDirectory), "utf8"); + + // 추출기가 컴파일러 호출을 관찰해야 하므로 FROM-CACHE 로 넘어가면 안 된다 (#1014). + assert.doesNotMatch(withoutComments(source), /--build-cache/); + assert.match(source, /cache-read-only:\s*true/); +}); + +test("the local Gradle default stays uncached", async () => { + // #996 은 CI 한정이다. 로컬은 손상된 cache 엔트리 사례 때문에 여전히 꺼 둔다. + const properties = await readFile(new URL("../../gradle.properties", import.meta.url), "utf8"); + + assert.match(properties, /^org\.gradle\.caching=false$/m); +}); diff --git a/.github/workflows/build-cache-warm.yml b/.github/workflows/build-cache-warm.yml new file mode 100644 index 0000000000..ba7dd13656 --- /dev/null +++ b/.github/workflows/build-cache-warm.yml @@ -0,0 +1,72 @@ +name: Build Cache Warm + +# PR 검증 job 4개가 같은 commit 의 같은 debug variant 를 각자 처음부터 컴파일하던 문제(#996)의 +# 공급 측이다. lint·unit-test·screenshot 은 --build-cache 로 읽을 준비가 돼 있지만, PR job 은 +# cache poisoning 방지를 위해 전부 cache-read-only 라 아무도 캐시를 채우지 않는다. +# +# 이 워크플로가 develop push 에서 유일한 writer 다. CodeQL 이 그 역할을 겸하던 시절에는 +# 캐시를 채우려고 --build-cache 를 붙였다가 추출기가 컴파일러 호출을 관찰하지 못하는 +# 문제로 되돌려야 했다(#1014). 워밍과 분석을 분리해 둘 다 제 조건에서 돌게 한다. +on: + push: + branches: [develop] + workflow_dispatch: + +# 워밍은 취소하지 않는다. develop 은 PR 을 연달아 머지하는 브랜치라 cancel-in-progress 면 +# 마지막 push 를 뺀 전부가 중도에 죽고, 캐시를 저장하는 post 스텝까지 못 간다. +concurrency: + group: build-cache-warm-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: read + +jobs: + warm: + name: Warm Gradle build cache + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - name: Clone repo + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up JDK 21 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 + with: + distribution: 'temurin' + java-version: '21' + + # 이 저장소에서 cache-read-only: false 를 쓰는 유일한 자리다. setup-gradle 의 기본값은 + # «default branch 에서만 write» 인데 이 저장소의 default branch 는 main 이라, develop 에서 + # 쓰려면 명시해야 한다. 캐시 대상에는 caches/build-cache-1 이 포함된다. + - name: Set up Gradle and validate wrapper + uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + with: + cache-provider: basic + cache-read-only: false + validate-wrappers: true + + - name: Set up CI-only app configuration + uses: ./.github/actions/setup-ci-config + + # PR 이 실제로 돌리는 태스크와 같은 것을 돌려야 그 산출물이 재사용된다. 목록은 + # lint.yml(ktlintCheck·lintDebug)과 unit-test.yml 의 태스크를 합친 것이다. + # + # screenshot 은 넣지 않는다 — docker 컨테이너 안에서 돌아 캐시 키에 들어가는 실행 + # 환경이 러너와 갈릴 수 있고, 그 job 이 실제로 아끼는 컴파일 산출물은 여기서 이미 + # 채워진다. + # + # --continue 는 develop 이 깨져 있을 때도 통과한 만큼은 캐시에 남기기 위한 것이다. + # 워밍은 게이트가 아니므로 실패가 머지를 막지 않는다. + - name: Populate build cache with the tasks pull requests run + run: >- + ./gradlew + ktlintCheck + lintDebug + :koverXmlReportCi + :konsist:test + :build-logic:test + :app:compileDebugAndroidTestKotlin + --continue + --parallel + --build-cache diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index b9e5090478..7d8d53bc55 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -49,12 +49,16 @@ jobs: distribution: temurin java-version: '21' + # 읽기 전용이다 — push 에서도 쓰지 않는다. 이 job 은 --build-cache 를 쓸 수 없어 + # (아래 주석) build-cache-1 이 빈 Gradle User Home 을 저장하게 되는데, Actions 캐시는 + # 같은 키를 덮어쓰지 못하므로 그것이 먼저 저장되면 build-cache-warm.yml 이 채운 + # 캐시가 올라갈 자리를 잃는다. develop push 의 캐시 writer 는 그 워크플로 하나다 (#996). - name: Set up Gradle and validate wrapper if: matrix.build_mode == 'manual' uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 with: cache-provider: basic - cache-read-only: ${{ github.event_name == 'pull_request' }} + cache-read-only: true validate-wrappers: true - name: Set up CI-only app configuration