diff --git a/.github/actions/gradle-task/action.yml b/.github/actions/gradle-task/action.yml index 50b9874beb..855cac7473 100644 --- a/.github/actions/gradle-task/action.yml +++ b/.github/actions/gradle-task/action.yml @@ -41,6 +41,18 @@ runs : cache-read-only : false gradle-home-cache-cleanup : true + # Calculate all the hashes for keys just one time. + # These should only be referenced before the actual task action, since that action + # may generate changes and we want the final cache key to reflect its current state. + - name : Calculate hashes + id : hashes + shell: bash + run : | + echo "lib_versions=${{ hashFiles('**/libs.versions.toml') }}" >> $GITHUB_OUTPUT + echo "gradle_props=${{ hashFiles('**/gradle.properties') }}" >> $GITHUB_OUTPUT + echo "gradle_kts=${{ hashFiles('**/*.gradle.kts') }}" >> $GITHUB_OUTPUT + echo "src_kt=${{ hashFiles('**/src/**/*.kt') }}" >> $GITHUB_OUTPUT + # Attempt to restore from the write-cache-key, or fall back to a partial match for the write key. # Skipped if the write-cache-key wasn't set. # This step's "cache_hit" output will only be true if an exact match was found. @@ -51,9 +63,15 @@ runs : with : path : | ~/.gradle/caches/build-cache-1 - ./**/build/** - key : ${{runner.os}}-${{inputs.write-cache-key}}-${{hashFiles('**/*.gradle.kt*')}}-${{hashFiles('**/libs.versions.toml')}}-${{hashFiles('**/gradle.properties')}} - restore-keys : ${{runner.os}}-${{inputs.write-cache-key}} + ~/.konan + ./**/build + ./**/.gradle + key : ${{runner.os}}-${{inputs.write-cache-key}}-${{steps.hashes.outputs.lib_versions}}-${{steps.hashes.outputs.gradle_props}}-${{steps.hashes.outputs.gradle_kts}}-${{steps.hashes.outputs.src_kt}} + restore-keys : | + ${{runner.os}}-${{inputs.write-cache-key}}-${{steps.hashes.outputs.lib_versions}}-${{steps.hashes.outputs.gradle_props}}-${{steps.hashes.outputs.gradle_kts}} + ${{runner.os}}-${{inputs.write-cache-key}}-${{steps.hashes.outputs.lib_versions}}-${{steps.hashes.outputs.gradle_props}} + ${{runner.os}}-${{inputs.write-cache-key}}-${{steps.hashes.outputs.lib_versions}} + ${{runner.os}}-${{inputs.write-cache-key}} # Attempt to restore from the restore-cache-key, or fall back to a partial match for the restore key. # Skipped if the restore-cache-key wasn't set, or if the write-cache-key restore had an exact match. @@ -63,9 +81,15 @@ runs : with : path : | ~/.gradle/caches/build-cache-1 - ./**/build/** - key : ${{runner.os}}-${{inputs.restore-cache-key}}-${{hashFiles('**/*.gradle.kt*')}}-${{hashFiles('**/libs.versions.toml')}}-${{hashFiles('**/gradle.properties')}} - restore-keys : ${{runner.os}}-${{inputs.restore-cache-key}} + ~/.konan + ./**/build + ./**/.gradle + key : ${{runner.os}}-${{inputs.restore-cache-key}}-${{steps.hashes.outputs.lib_versions}}-${{steps.hashes.outputs.gradle_props}}-${{steps.hashes.outputs.gradle_kts}}-${{steps.hashes.outputs.src_kt}} + restore-keys : | + ${{runner.os}}-${{inputs.restore-cache-key}}-${{steps.hashes.outputs.lib_versions}}-${{steps.hashes.outputs.gradle_props}}-${{steps.hashes.outputs.gradle_kts}} + ${{runner.os}}-${{inputs.restore-cache-key}}-${{steps.hashes.outputs.lib_versions}}-${{steps.hashes.outputs.gradle_props}} + ${{runner.os}}-${{inputs.restore-cache-key}}-${{steps.hashes.outputs.lib_versions}} + ${{runner.os}}-${{inputs.restore-cache-key}} - uses : gradle/wrapper-validation-action@v1 @@ -92,8 +116,10 @@ runs : with : path : | ~/.gradle/caches/build-cache-1 - ./**/build/** - key : ${{runner.os}}-${{inputs.write-cache-key}}-${{hashFiles('**/*.gradle.kt*')}}-${{hashFiles('**/libs.versions.toml')}}-${{hashFiles('**/gradle.properties')}} + ~/.konan + ./**/build + ./**/.gradle + key : ${{runner.os}}-${{inputs.write-cache-key}}-${{hashFiles('**/libs.versions.toml')}}-${{hashFiles('**/gradle.properties')}}-${{hashFiles('**/*.gradle.kts')}}-${{hashFiles('**/src/**/*.kt')}} - name : Upload heap dump if : failure() diff --git a/.github/actions/gradle-tasks-with-emulator/action.yml b/.github/actions/gradle-tasks-with-emulator/action.yml new file mode 100644 index 0000000000..ef628fc4ef --- /dev/null +++ b/.github/actions/gradle-tasks-with-emulator/action.yml @@ -0,0 +1,88 @@ +name : Run Android Instrumentation Tests with Artifact and AVD Caching +description: This action sets up Gradle, runs a preparatory task, runs Android tests on an emulator, and uploads test results. + +inputs: + prepare-task: + description: 'Gradle task for preparing necessary artifacts. Supports multi-line input.' + required: true + test-task: + description: 'Gradle task for running instrumentation tests. Supports multi-line input.' + required: true + api-level : + description : 'The Android SDK api level, like `29`' + required : true + build-root-directory : + description : 'Path to the root directory of the build' + required : false + java-version : + description : 'The Java version to set up.' + default : '11' + distribution : + description : 'The JDK distribution to use.' + default : 'zulu' + restore-cache-key : + description : 'The unique identifier for the associated cache. Any other consumers or producers for this cache must use the same name.' + default : 'null' + write-cache-key : + description : 'The unique identifier for the associated cache. Any other consumers or producers for this cache must use the same name.' + default : 'null' + +runs : + using : 'composite' + steps : + + # Create or fetch the artifacts used for these tests. + - name : Run ${{ inputs.prepare-task }} + uses : ./.github/actions/gradle-task + with : + build-root-directory : ${{ inputs.build-root-directory }} + distribution : ${{ inputs.distribution }} + java-version : ${{ inputs.java-version }} + restore-cache-key : ${{ inputs.restore-cache-key }} + task : ${{ inputs.prepare-task }} + write-cache-key : ${{ inputs.write-cache-key }} + + # Get the AVD if it's already cached. + - name: AVD cache + uses: actions/cache@v3 + id: avd-cache + with: + path: | + ~/.android/avd/* + ~/.android/adb* + key: avd-${{ matrix.api-level }} + + # If the AVD cache didn't exist, create an AVD and cache it. + - name: create AVD and generate snapshot for caching + if: steps.avd-cache.outputs.cache-hit != 'true' + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: ${{ inputs.api-level }} + arch : x86_64 + disable-animations: false + emulator-boot-timeout: 12000 + emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + force-avd-creation: false + profile : Galaxy Nexus + ram-size: 4096M + script: echo "Generated AVD snapshot." + + # Run the actual emulator tests. + # At this point every task should be up-to-date and the AVD should be ready to go. + - name: run tests + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: ${{ inputs.api-level }} + arch : x86_64 + disable-animations: true + emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + force-avd-creation: false + profile : Galaxy Nexus + script : ./gradlew ${{ inputs.test-task }} + + - name : Upload results + if : ${{ always() }} + uses : actions/upload-artifact@v3 + with : + name : instrumentation-test-results + path : ./**/build/reports/androidTests/connected/** diff --git a/.github/workflows/kotlin.yml b/.github/workflows/kotlin.yml index 72a305a4ec..36915096f3 100644 --- a/.github/workflows/kotlin.yml +++ b/.github/workflows/kotlin.yml @@ -14,22 +14,22 @@ concurrency : jobs : - build-all: - name: Build all - runs-on: macos-latest - steps: - - uses: actions/checkout@v3 - - - name: main build - uses: ./.github/actions/gradle-task - with: - task: compileKotlin compileDebugKotlin - write-cache-key: main-build-artifacts + build-all : + name : Build all + runs-on : macos-latest + steps : + - uses : actions/checkout@v3 + + - name : main build + uses : ./.github/actions/gradle-task + with : + task : compileKotlin assembleDebug + write-cache-key : main-build-artifacts dokka : name : Assemble & Dokka runs-on : ubuntu-latest - needs: build-all + needs : build-all steps : - uses : actions/checkout@v3 @@ -39,6 +39,19 @@ jobs : task : siteDokka write-cache-key : main-build-artifacts + shards-and-version : + name : Shard Matrix Yaml + runs-on : ubuntu-latest + steps : + - uses : actions/checkout@v3 + + - name : check published artifacts + uses : ./.github/actions/gradle-task-with-commit + with : + check-task : connectedCheckShardMatrixYamlCheck checkVersionIsSnapshot + fix-task : connectedCheckShardMatrixYamlUpdate checkVersionIsSnapshot + write-cache-key : build-logic + artifacts-check : name : ArtifactsCheck # the `artifactsCheck` task has to run on macOS in order to see the iOS KMP artifacts @@ -101,7 +114,7 @@ jobs : android-lint : name : Android Lint runs-on : ubuntu-latest - needs: build-all + needs : build-all timeout-minutes : 20 steps : - uses : actions/checkout@v3 @@ -114,7 +127,7 @@ jobs : check : name : Check runs-on : ubuntu-latest - needs: build-all + needs : build-all timeout-minutes : 20 steps : - uses : actions/checkout@v3 @@ -122,9 +135,8 @@ jobs : uses : ./.github/actions/gradle-task with : task : | - checkVersionIsSnapshot allTests - test + test --continue restore-cache-key : build-logic write-cache-key : main-build-artifacts @@ -262,87 +274,42 @@ jobs : steps : - uses : actions/checkout@v3 - ## Build before running tests, using cache. - - name : Build instrumented tests - uses : ./.github/actions/gradle-task - with : - task : :benchmarks:performance-poetry:complex-poetry:assembleDebugAndroidTest - restore-cache-key : main-build-artifacts - - ## Actual task - - name : Render Pass Counting Test - uses : reactivecircus/android-emulator-runner@v2 + - name : Instrumented tests + uses : ./.github/actions/gradle-tasks-with-emulator with : - # @ychescale9 suspects Galaxy Nexus is the fastest one - profile : Galaxy Nexus api-level : ${{ matrix.api-level }} - arch : x86_64 - script : ./gradlew :benchmarks:performance-poetry:complex-poetry:connectedCheck --continue - - - name : Upload results - if : ${{ always() }} - uses : actions/upload-artifact@v3 - with : - name : renderpass-counting-results-${{ matrix.api-level }} - path : ./**/build/reports/androidTests/connected/** - - build-instrumentation-tests : - name : Build Instrumentation tests - runs-on : macos-latest - needs: build-all - timeout-minutes : 45 - steps : - - uses : actions/checkout@v3 - - - name : Build instrumented tests - uses : ./.github/actions/gradle-task - with : - task : assembleDebugAndroidTest - restore-cache-key : main-build-artifacts - write-cache-key : androidTest-build-artifacts + prepare-task : :benchmarks:performance-poetry:complex-poetry:prepareDebugAndroidTestArtifacts + test-task : :benchmarks:performance-poetry:complex-poetry:connectedCheck --continue + restore-cache-key : androidTest-build-artifacts instrumentation-tests : name : Instrumentation tests - needs: build-instrumentation-tests runs-on : macos-latest timeout-minutes : 45 strategy : # Allow tests to continue on other devices if they fail on one device. fail-fast : false matrix : + # Unclear that older versions actually honor command to disable animation. + # Newer versions are reputed to be too slow: https://github.com/ReactiveCircus/android-emulator-runner/issues/222 api-level : - 29 - # Unclear that older versions actually honor command to disable animation. - # Newer versions are reputed to be too slow: https://github.com/ReactiveCircus/android-emulator-runner/issues/222 + ### + shardNum : [ 1, 2, 3 ] + ### steps : - uses : actions/checkout@v3 - # This really just pulls the cache from the dependency job - - name : Build instrumented tests - uses : ./.github/actions/gradle-task + - name : Instrumented tests + uses : ./.github/actions/gradle-tasks-with-emulator with : - task : assembleDebugAndroidTest - restore-cache-key : androidTest-build-artifacts - - ## Actual task - - name : Instrumentation Tests - uses : reactivecircus/android-emulator-runner@v2 - with : - # @ychescale9 suspects Galaxy Nexus is the fastest one - profile : Galaxy Nexus api-level : ${{ matrix.api-level }} - arch : x86_64 - # Skip the benchmarks as this is running on emulators - script : ./gradlew connectedCheck -x :benchmarks:dungeon-benchmark:connectedCheck -x :benchmarks:performance-poetry:complex-benchmark:connectedCheck -x :benchmarks:performance-poetry:complex-poetry:connectedCheck - - - name : Upload results - if : ${{ always() }} - uses : actions/upload-artifact@v3 - with : - name : instrumentation-test-results-${{ matrix.api-level }} - path : ./**/build/reports/androidTests/connected/** + prepare-task : prepareConnectedCheckShard${{matrix.shardNum}} + test-task : connectedCheckShard${{matrix.shardNum}} -x :benchmarks:dungeon-benchmark:connectedCheck -x :benchmarks:performance-poetry:complex-benchmark:connectedCheck -x :benchmarks:performance-poetry:complex-poetry:connectedCheck + write-cache-key : androidTest-build-artifacts-${{matrix.shardNum}} + restore-cache-key : main-build-artifacts - conflate-renderings-instrumentation-tests : + runtime-instrumentation-tests : name : Conflate Stale Renderings Instrumentation tests runs-on : macos-latest timeout-minutes : 45 @@ -352,128 +319,21 @@ jobs : matrix : api-level : - 29 - # Unclear that older versions actually honor command to disable animation. - # Newer versions are reputed to be too slow: https://github.com/ReactiveCircus/android-emulator-runner/issues/222 + ### + shardNum : [ 1, 2, 3 ] + ### + runtime : [ conflate, baseline-stateChange, conflate-stateChange ] steps : - uses : actions/checkout@v3 - ## Build before running tests, using cache. - - name : Build instrumented tests - uses : ./.github/actions/gradle-task + - name : Instrumented tests + uses : ./.github/actions/gradle-tasks-with-emulator with : - # Unfortunately I don't think we can key this cache based on our project property so - # we clean and rebuild. - task : clean assembleDebugAndroidTest -Pworkflow.runtime=conflate - - ## Actual task - - name : Instrumentation Tests - uses : reactivecircus/android-emulator-runner@v2 - with : - # @ychescale9 suspects Galaxy Nexus is the fastest one - profile : Galaxy Nexus api-level : ${{ matrix.api-level }} - arch : x86_64 - # Skip the benchmarks as this is running on emulators - script : ./gradlew connectedCheck -x :benchmarks:dungeon-benchmark:connectedCheck -x :benchmarks:performance-poetry:complex-benchmark:connectedCheck -x :benchmarks:performance-poetry:complex-poetry:connectedCheck -Pworkflow.runtime=conflate - - - name : Upload results - if : ${{ always() }} - uses : actions/upload-artifact@v3 - with : - name : instrumentation-test-results-${{ matrix.api-level }} - path : ./**/build/reports/androidTests/connected/** - - stateChange-runtime-instrumentation-tests : - name : Render on State Change Only Instrumentation tests - runs-on : macos-latest - timeout-minutes : 45 - strategy : - # Allow tests to continue on other devices if they fail on one device. - fail-fast : false - matrix : - api-level : - - 29 - # Unclear that older versions actually honor command to disable animation. - # Newer versions are reputed to be too slow: https://github.com/ReactiveCircus/android-emulator-runner/issues/222 - steps : - - uses : actions/checkout@v3 - - name : set up JDK 11 - uses : actions/setup-java@v3 - with : - distribution : 'zulu' - java-version : 11 - - ## Build before running tests, using cache. - - name : Build instrumented tests - uses : ./.github/actions/gradle-task - with : - # Unfortunately I don't think we can key this cache based on our project property so - # we clean and rebuild. - task : clean assembleDebugAndroidTest -Pworkflow.runtime=baseline-stateChange - - ## Actual task - - name : Instrumentation Tests - uses : reactivecircus/android-emulator-runner@v2 - with : - # @ychescale9 suspects Galaxy Nexus is the fastest one - profile : Galaxy Nexus - api-level : ${{ matrix.api-level }} - arch : x86_64 - # Skip the benchmarks as this is running on emulators - script : ./gradlew connectedCheck -x :benchmarks:dungeon-benchmark:connectedCheck -x :benchmarks:performance-poetry:complex-benchmark:connectedCheck -x :benchmarks:performance-poetry:complex-poetry:connectedCheck -Pworkflow.runtime=baseline-stateChange - - - name : Upload results - if : ${{ always() }} - uses : actions/upload-artifact@v3 - with : - name : stateChange-instrumentation-test-results-${{ matrix.api-level }} - path : ./**/build/reports/androidTests/connected/** - - conflate-stateChange-runtime-instrumentation-tests : - name : Render on State Change Only and Conflate Stale Renderings Instrumentation tests - runs-on : macos-latest - timeout-minutes : 45 - strategy : - # Allow tests to continue on other devices if they fail on one device. - fail-fast : false - matrix : - api-level : - - 29 - # Unclear that older versions actually honor command to disable animation. - # Newer versions are reputed to be too slow: https://github.com/ReactiveCircus/android-emulator-runner/issues/222 - steps : - - uses : actions/checkout@v3 - - name : set up JDK 11 - uses : actions/setup-java@v3 - with : - distribution : 'zulu' - java-version : 11 - - ## Build before running tests, using cache. - - name : Build instrumented tests - uses : ./.github/actions/gradle-task - with : - # Unfortunately I don't think we can key this cache based on our project property so - # we clean and rebuild. - task : clean assembleDebugAndroidTest -Pworkflow.runtime=conflate-stateChange - - ## Actual task - - name : Instrumentation Tests - uses : reactivecircus/android-emulator-runner@v2 - with : - # @ychescale9 suspects Galaxy Nexus is the fastest one - profile : Galaxy Nexus - api-level : ${{ matrix.api-level }} - arch : x86_64 - # Skip the benchmarks as this is running on emulators - script : ./gradlew connectedCheck -x :benchmarks:dungeon-benchmark:connectedCheck -x :benchmarks:performance-poetry:complex-benchmark:connectedCheck -x :benchmarks:performance-poetry:complex-poetry:connectedCheck -Pworkflow.runtime=conflate-stateChange - - - name : Upload results - if : ${{ always() }} - uses : actions/upload-artifact@v3 - with : - name : conflate-stateChange-instrumentation-test-results-${{ matrix.api-level }} - path : ./**/build/reports/androidTests/connected/** + prepare-task : prepareConnectedCheckShard${{matrix.shardNum}} -Pworkflow.runtime=${{matrix.runtime}} + test-task : connectedCheckShard${{matrix.shardNum}} -Pworkflow.runtime=${{matrix.runtime}} -x :benchmarks:dungeon-benchmark:connectedCheck -x :benchmarks:performance-poetry:complex-benchmark:connectedCheck -x :benchmarks:performance-poetry:complex-poetry:connectedCheck + write-cache-key : androidTest-build-artifacts-${{matrix.shardNum}}-${{matrix.runtime}} + restore-cache-key : main-build-artifacts all-green : if : always() @@ -483,8 +343,6 @@ jobs : - api-check - artifacts-check - check - - conflate-renderings-instrumentation-tests - - conflate-stateChange-runtime-instrumentation-tests - dependency-guard - dokka - instrumentation-tests @@ -495,7 +353,8 @@ jobs : - jvm-stateChange-runtime-test - ktlint - performance-tests - - stateChange-runtime-instrumentation-tests + - runtime-instrumentation-tests + - shards-and-version - tutorials steps : diff --git a/build-logic/build.gradle.kts b/build-logic/build.gradle.kts index 21caf00735..b17cf94ea6 100644 --- a/build-logic/build.gradle.kts +++ b/build-logic/build.gradle.kts @@ -23,6 +23,7 @@ dependencies { implementation(libs.squareup.moshi) implementation(libs.squareup.moshi.adapters) implementation(libs.vanniktech.publish) + implementation(libs.java.diff.utils) ksp(libs.squareup.moshi.codegen) } diff --git a/build-logic/src/main/java/com/squareup/workflow1/buildsrc/diff.kt b/build-logic/src/main/java/com/squareup/workflow1/buildsrc/diff.kt new file mode 100644 index 0000000000..4be2b35912 --- /dev/null +++ b/build-logic/src/main/java/com/squareup/workflow1/buildsrc/diff.kt @@ -0,0 +1,56 @@ +package com.squareup.workflow1.buildsrc + +import com.github.difflib.text.DiffRow.Tag +import com.github.difflib.text.DiffRowGenerator +import com.squareup.workflow1.buildsrc.Color.Companion.colorized +import com.squareup.workflow1.buildsrc.Color.LIGHT_GREEN +import com.squareup.workflow1.buildsrc.Color.LIGHT_YELLOW + +fun diffString(oldStr: String, newStr: String): String { + + return buildString { + + val rows = DiffRowGenerator.create() + .showInlineDiffs(true) + .inlineDiffByWord(true) + .oldTag { _: Boolean? -> "" } + .newTag { _: Boolean? -> "" } + .build() + .generateDiffRows(oldStr.lines(), newStr.lines()) + + val linePadding = rows.size.toString().length + 1 + + rows.forEachIndexed { line, diffRow -> + if (diffRow.tag != Tag.EQUAL) { + append("line ${line.inc().toString().padEnd(linePadding)} ") + } + + if (diffRow.tag == Tag.CHANGE || diffRow.tag == Tag.DELETE) { + appendLine("-- ${diffRow.oldLine}".colorized(LIGHT_YELLOW)) + } + if (diffRow.tag == Tag.CHANGE) { + append(" " + " ".repeat(linePadding)) + } + if (diffRow.tag == Tag.CHANGE || diffRow.tag == Tag.INSERT) { + appendLine("++ ${diffRow.newLine}".colorized(LIGHT_GREEN)) + } + } + } +} + +@Suppress("MagicNumber") +internal enum class Color(val code: Int) { + LIGHT_GREEN(92), + LIGHT_YELLOW(93); + + companion object { + + private val supported = "win" !in System.getProperty("os.name").lowercase() + + fun String.colorized(color: Color) = if (supported) { + "\u001B[${color.code}m$this\u001B[0m" + } else { + this + } + } +} diff --git a/build-logic/src/main/java/com/squareup/workflow1/buildsrc/shardConnectedChecks.kt b/build-logic/src/main/java/com/squareup/workflow1/buildsrc/shardConnectedChecks.kt new file mode 100644 index 0000000000..cdf1e59762 --- /dev/null +++ b/build-logic/src/main/java/com/squareup/workflow1/buildsrc/shardConnectedChecks.kt @@ -0,0 +1,233 @@ +package com.squareup.workflow1.buildsrc + +import com.squareup.workflow1.buildsrc.sharding.ShardMatrixYamlTask.Companion.registerYamlShardsTasks +import org.gradle.api.GradleException +import org.gradle.api.Project +import org.gradle.kotlin.dsl.provideDelegate +import kotlin.LazyThreadSafetyMode.NONE + +private const val SHARD_COUNT = 3 + +/** + * Create "shard" tasks which collectively depend upon all Android `connectedCheck` tasks in the + * entire project. + * + * Each shard depends upon the `connectedCheck` tasks of some subset of Android projects. + * Projects are assigned to a shard by counting the number of `@Test` annotations within their + * `androidTest` directory, then associating those projects to a shard in a round-robin fashion. + * + * These shards are invoked in CI using a GitHub Actions matrix. If the number of shards changes, + * the `connectedCheckShardMatrixYamlUpdate` task can automatically update the workflow file so + * that they're all invoked. + * + * The shard tasks are invoked as: + * ```shell + * # roughly 1/3 of the tests + * ./gradlew connectedCheckShard1 + * # the second third + * ./gradlew connectedCheckShard2 + * # the last third + * ./gradlew connectedCheckShard3 + * ``` + * + * @param target the root project which gets the shard tasks + */ +fun shardConnectedCheckTasks(target: Project) { + if (target != target.rootProject) { + throw GradleException("Only add connectedCheck shard tasks from the root project.") + } + + target.registerYamlShardsTasks( + shardCount = SHARD_COUNT, + startTagName = "### ", + endTagName = "### ", + taskNamePart = "connectedCheck", + yamlFile = target.rootProject.file(".github/workflows/kotlin.yml") + ) + + // Calculate the cost of each project's tests + val projectsWithTestCount = lazy(NONE) { + target.subprojects + // Only Android projects can have these tasks. + // Use the KGP Android plugin instead of AGP since KGP has only one ID to look for. + .filter { it.plugins.hasPlugin("org.jetbrains.kotlin.android") } + .map { it to it.androidTestCost() } + } + + // Assign each project to a shard. + // The values are lazy so that the work only happens at task configuration time, but they're + // outside the task configuration block so that it only happens once. + val shardAssignments = projectsWithTestCount.shards() + + val connectedTestName = "connectedCheck" + + shardAssignments.forEach { shard -> + + val projects by shard.projectsLazy + + val paths by lazy { + projects.joinToString(prefix = "[ ", postfix = " ]") { it.path } + } + + target.tasks.register("connectedCheckShard${shard.number}") { + + group = "Verification" + + validateSharding( + projectsWithTestCount = projectsWithTestCount.value, + shardAssignments = shardAssignments + ) + + description = "Runs $connectedTestName in projects: $paths" + + val assignedTests = projects.map { project -> + project.tasks.matching { it.name == connectedTestName } + } + + dependsOn(assignedTests) + } + + target.tasks.register("prepareConnectedCheckShard${shard.number}") { + + validateSharding( + projectsWithTestCount = projectsWithTestCount.value, + shardAssignments = shardAssignments + ) + + description = "Builds all artifacts for running connected tests in projects: $paths" + + val regex = Regex("""prepare[A-Z]\w*AndroidTestArtifacts""") + + val prepareTasks = projects.map { project -> + project.tasks.matching { it.name.matches(regex) } + } + + dependsOn(prepareTasks) + } + } +} + +/** + * Assigns each project to a shard, distributing them by the number of tests they have. + * The combined test costs of all shards should be approximately equal. + * + * There's a lot of `Lazy` here so that defer parsing all the tests until task configuration. + * If the tasks aren't actually being invoked, no parsing happens. + * + * @receiver Every project with its associated test cost. + * @return A list of shards, where each shard encapsulates a subset of projects. + */ +private fun Lazy>>.shards(): List { + + val shards by lazy { + List>>(SHARD_COUNT) { mutableListOf() } + .also { shards -> + + fun next(): MutableList> { + return shards.minBy { it.sumOf { (_, count) -> count } } + } + + // Sort the projects by descending test cost, then fall back to the project paths + // The path sort is just so that the shard composition is stable. If the shard composition + // isn't stable, the shard tasks may not be up-to-date and build caching in CI is broken. + val sorted = value.sortedWith(compareBy({ it.second }, { it.first })) + .reversed() + + for (pair in sorted) { + next().add(pair) + } + } + } + + return List(SHARD_COUNT) { index -> + Shard( + number = index + 1, + testCountLazy = lazy { shards[index].sumOf { (_, count) -> count } }, + projectsLazy = lazy { shards[index].map { (project, _) -> project } } + ) + } +} + +private data class Shard( + val number: Int, + val testCountLazy: Lazy, + val projectsLazy: Lazy> +) { + val testCount by testCountLazy + val projects by projectsLazy + override fun toString(): String { + return "Shard(number=$number, testCount=$testCount, projects=${projects.joinToString("\n") { it.path }})" + } +} + +private fun validateSharding( + projectsWithTestCount: List>, + shardAssignments: List, +) { + + val allShardsText by lazy(NONE) { shardAssignments.joinToString("\n") } + + if (shardAssignments.size != SHARD_COUNT) { + throw GradleException( + "Unexpected shard configuration. There should be $SHARD_COUNT shards, " + + "but `shardAssignments` is:\n$allShardsText" + ) + } + + val allShardedProjects = shardAssignments.flatMap { it.projects } + + val duplicates = allShardedProjects.groupingBy { it } + .eachCount() + .filter { it.value > 1 } + .keys + + if (duplicates.isNotEmpty()) { + throw GradleException( + "There are duplicated projects in shards.\n" + + "Duplicated projects: ${duplicates.map { it.path }}\n" + + "All shards:\n$allShardsText" + ) + } + + val missingInShards = projectsWithTestCount + .map { it.first } + .minus(allShardedProjects.toSet()) + + if (missingInShards.isNotEmpty()) { + throw GradleException( + "There are projects missing from all shards.\n" + + "Missing projects: $missingInShards\n" + + "All shards:\n$allShardsText" + ) + } +} + +/** + * matches: + * ``` + * @org.junit.Test + * @Test + * ``` + */ +private val testAnnotationRegex = """@(?:org\.junit\.)?Test\s+""".toRegex() + +/** + * Counts all the `androidTest` functions annotated with `@Test` within this project. + * + * Each test function has a cost of 1. A project with 20 tests has a cost of 20. + */ +private fun Project.androidTestCost(): Int { + + val androidTestSrc = file("src/androidTest/java") + + if (!androidTestSrc.exists()) return 0 + + return androidTestSrc + .walkTopDown() + .filter { it.isFile && it.extension == "kt" } + .sumOf { file -> + val fileText = file.readText() + + testAnnotationRegex.findAll(fileText).count() + } +} diff --git a/build-logic/src/main/java/com/squareup/workflow1/buildsrc/sharding/ShardMatrixYamlTask.kt b/build-logic/src/main/java/com/squareup/workflow1/buildsrc/sharding/ShardMatrixYamlTask.kt new file mode 100644 index 0000000000..0437afbc4a --- /dev/null +++ b/build-logic/src/main/java/com/squareup/workflow1/buildsrc/sharding/ShardMatrixYamlTask.kt @@ -0,0 +1,227 @@ +package com.squareup.workflow1.buildsrc.sharding + +import com.android.build.gradle.internal.tasks.factory.dependsOn +import com.squareup.workflow1.buildsrc.diffString +import org.gradle.api.DefaultTask +import org.gradle.api.GradleException +import org.gradle.api.Project +import org.gradle.api.model.ObjectFactory +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputFile +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity.RELATIVE +import org.gradle.api.tasks.TaskAction +import org.gradle.internal.logging.text.StyledTextOutput +import org.gradle.internal.logging.text.StyledTextOutputFactory +import org.gradle.language.base.plugins.LifecycleBasePlugin +import java.io.File +import javax.inject.Inject +import kotlin.LazyThreadSafetyMode.NONE + +/** + * This task manages test shard matrix configuration in a GitHub Actions workflow file, + * ensuring that it matches the value of [numShards]. + * + * @property yamlFile The CI configuration file this task works on. + * @property startTagProperty The start tag to identify the matrix section in the CI configuration file. + * @property endTagProperty The end tag to identify the matrix section in the CI configuration file. + * @property numShards The number of shards to use for tests. + * @property autoCorrect If `true`, the task will automatically correct any incorrect test shard + * matrix configurations. + * @property updateTaskName The name of the task that updates the test shard matrix. + */ +abstract class ShardMatrixYamlTask @Inject constructor( + objectFactory: ObjectFactory +) : DefaultTask() { + + /** kotlin.yml */ + @get:InputFile + @get:PathSensitive(RELATIVE) + val yamlFile = objectFactory.fileProperty() + + /** + * Used to identify the start of the matrix. + * Everything after this tag and before the end tag will be overwritten. + * + * ex: `### ` + */ + @get:Input abstract val startTagProperty: Property + private val startTag: String + get() = startTagProperty.get() + + /** + * Used to identify the end of the matrix. + * ex: `### ` + * */ + @get:Input abstract val endTagProperty: Property + private val endTag: String + get() = endTagProperty.get() + + /** for `3`, the matrix value would be `[ 1, 2, 3]` */ + @get:Input + abstract val numShards: Property + + /** + * If true the file will be updated. If false, the task will fail if the matrix is out of date. + */ + @get:Input + abstract val autoCorrect: Property + + @get:Input + abstract val updateTaskName: Property + + private val matrixSectionRegex by lazy(NONE) { + + val startTagEscaped = Regex.escape(startTag) + val endTagEscaped = Regex.escape(endTag) + + Regex("""( *)(.*$startTagEscaped.*\n)[\s\S]+?(.*$endTagEscaped)""") + } + + @TaskAction + fun execute() { + val ciFile = requireCiFile() + + val ciText = ciFile.readText() + + val newText = replaceYamlSections(ciText) + + if (ciText != newText) { + + if (autoCorrect.get()) { + + ciFile.writeText(newText) + + val message = "Updated the test shard matrix in the CI file.\n" + + "\tfile://${yamlFile.get()}" + + services + .get(StyledTextOutputFactory::class.java) + .create("workflow-yaml-matrix") + .withStyle(StyledTextOutput.Style.Description) + .println(message) + + println() + println(diffString(ciText, newText)) + println() + } else { + val message = "The test shard matrix in the CI file is out of date.\n" + + "\tfile://${yamlFile.get()}\n\n" + + "Run ./gradlew ${updateTaskName.get()} to automatically update." + + throw GradleException(message) + } + } + } + + private fun replaceYamlSections(ciText: String): String { + + if (!ciText.contains(matrixSectionRegex)) { + val message = + "Couldn't find any `$startTag`/`$endTag` sections in the CI file:" + + "\tfile://${yamlFile.get()}\n\n" + + "\tSurround the matrix section with the comments '$startTag' and `$endTag':\n\n" + + "\t strategy:\n" + + "\t ### $startTag\n" + + "\t matrix:\n" + + "\t [ ... ]\n" + + "\t ### $endTag\n" + + throw GradleException(message) + } + + return ciText.replace(matrixSectionRegex) { match -> + + val (indent, startTag, closingLine) = match.destructured + + val newContent = createYaml(indent, numShards.get()) + + "$indent$startTag$newContent$closingLine" + } + } + + private fun requireCiFile(): File { + val ciFile = yamlFile.get().asFile + + require(ciFile.exists()) { + "Could not resolve file: file://$ciFile" + } + + return ciFile + } + + private fun createYaml( + indent: String, + numShards: Int + ): String { + + val shardList = buildString { + append("[ ") + repeat(numShards) { + val i = it + 1 + append("$i") + if (i < numShards) append(", ") + } + append(" ]") + } + + return "${indent}shardNum : $shardList\n" + } + + companion object { + /** + * Registers tasks to check and update the test shard matrix configuration in `kotlin.yml`. + * + * @param shardCount The number of test shards. + * @param startTagName The start tag to identify the matrix section in `kotlin.yml`. + * @param endTagName The end tag to identify the matrix section in `kotlin.yml`. + * @param taskNamePart The part of the sharded task name which will be prepended to + * the matrix update task names. + * @param yamlFile presumably `kotlin.yml`. + */ + fun Project.registerYamlShardsTasks( + shardCount: Int, + startTagName: String, + endTagName: String, + taskNamePart: String, + yamlFile: File + ) { + + require(yamlFile.exists()) { + "Could not resolve '$yamlFile'." + } + + val updateName = "${taskNamePart}ShardMatrixYamlUpdate" + val updateTask = tasks.register( + updateName, + ShardMatrixYamlTask::class.java + ) { + val task = this + task.yamlFile.set(yamlFile) + numShards.set(shardCount) + startTagProperty.set(startTagName) + endTagProperty.set(endTagName) + autoCorrect.set(true) + updateTaskName.set(updateName) + } + + val checkTask = tasks.register( + "${taskNamePart}ShardMatrixYamlCheck", + ShardMatrixYamlTask::class.java + ) { + val task = this + task.yamlFile.set(yamlFile) + numShards.set(shardCount) + startTagProperty.set(startTagName) + endTagProperty.set(endTagName) + autoCorrect.set(false) + updateTaskName.set(updateName) + mustRunAfter(updateTask) + } + + // Automatically run this check task when running the `check` lifecycle task + tasks.named(LifecycleBasePlugin.CHECK_TASK_NAME).dependsOn(checkTask) + } + } +} diff --git a/build-logic/src/main/java/kotlin-android.gradle.kts b/build-logic/src/main/java/kotlin-android.gradle.kts index a4f228adde..d2afd125a3 100644 --- a/build-logic/src/main/java/kotlin-android.gradle.kts +++ b/build-logic/src/main/java/kotlin-android.gradle.kts @@ -1,4 +1,6 @@ +import com.android.build.api.variant.AndroidComponentsExtension import com.squareup.workflow1.buildsrc.kotlinCommonSettings +import org.gradle.configurationcache.extensions.capitalized plugins { kotlin("android") @@ -10,3 +12,28 @@ extensions.getByType(JavaPluginExtension::class).apply { } project.kotlinCommonSettings(bomConfigurationName = "implementation") + +// For every variant which extends the `debug` type, create a new task which generates all the +// artifacts used in the associated `connected____AndroidTest` task. +extensions.configure>("androidComponents") { + onVariants(selector().withBuildType("debug")) { variant -> + + val nameCaps = variant.name.capitalized() + val testTask = "connected${nameCaps}AndroidTest" + + tasks.register("prepare${nameCaps}AndroidTestArtifacts") { + description = "Creates all artifacts used in `$testTask` without trying to execute tests." + + dependsOn(tasks.getByName(testTask).taskDependencies) + } + } +} + + +/* + +macOS-main-build-artifacts-09555650eb7d6a7cc5d80ddfef5d6ea7bcf31c6cacdf093c62360bd678cb852f-3bd2c177794706d31840536092bfe0e2022bde51249e328646cb585e83e3c119-11a8ec8535b59590a882b41cbf4c2a235a2995758775ba8f9d82847ed5f3f87c-470e8ec00b1701c50f78eb15d625d72753e92d468b096562a31ffab8e1d3f1dd +macOS-main-build-artifacts-09555650eb7d6a7cc5d80ddfef5d6ea7bcf31c6cacdf093c62360bd678cb852f-3bd2c177794706d31840536092bfe0e2022bde51249e328646cb585e83e3c119-ea20820e0c811509dddd86ac8a9f53b6d1bd0785caed7898f1ef2d3390cfae19-470e8ec00b1701c50f78eb15d625d72753e92d468b096562a31ffab8e1d3f1dd + + + */ diff --git a/build.gradle.kts b/build.gradle.kts index b2b5487f40..dbca9edf6d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,3 +1,4 @@ +import com.squareup.workflow1.buildsrc.shardConnectedCheckTasks import org.jetbrains.dokka.gradle.AbstractDokkaLeafTask import java.net.URL @@ -28,6 +29,8 @@ plugins { alias(libs.plugins.ktlint) } +shardConnectedCheckTasks(project) + subprojects { afterEvaluate { diff --git a/dependencies/classpath.txt b/dependencies/classpath.txt index 5db68a654c..5c1c3de28c 100644 --- a/dependencies/classpath.txt +++ b/dependencies/classpath.txt @@ -86,6 +86,7 @@ com.vanniktech:nexus:0.22.0 commons-codec:commons-codec:1.11 commons-io:commons-io:2.4 commons-logging:commons-logging:1.2 +io.github.java-diff-utils:java-diff-utils:4.12 io.grpc:grpc-api:1.39.0 io.grpc:grpc-context:1.39.0 io.grpc:grpc-core:1.39.0 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d972ffd55e..143f4e97f1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -46,6 +46,7 @@ google-material = "1.4.0" groovy = "3.0.9" jUnit = "4.13.2" +java-diff-utils = "4.12" javaParser = "3.24.0" kotest = "5.1.0" kotlin = "1.8.10" @@ -182,6 +183,8 @@ google-ksp = { module = "com.google.devtools.ksp:symbol-processing-gradle-plugin hamcrest = "org.hamcrest:hamcrest-core:2.2" +java-diff-utils = { module = "io.github.java-diff-utils:java-diff-utils", version.ref = "java-diff-utils" } + jetbrains-annotations = "org.jetbrains:annotations:19.0.0" junit = { module = "junit:junit", version.ref = "jUnit" }