diff --git a/build.gradle.kts b/build.gradle.kts index 61d086484..d6879aca1 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -75,7 +75,12 @@ tasks.withType().configureEach { val ktfmtVersion = libs.versions.ktfmt.get() -val externalFiles = listOf("SkateErrorHandler", "MemoizedSequence").map { "src/**/$it.kt" } +val externalFiles = listOf( + "SkateErrorHandler", + "MemoizedSequence", + "Publisher", + "Resolver", +).map { "src/**/$it.kt" } allprojects { apply(plugin = "com.diffplug.spotless") diff --git a/slack-plugin/best-practices-baseline.json b/slack-plugin/best-practices-baseline.json new file mode 100644 index 000000000..a59c05dd8 --- /dev/null +++ b/slack-plugin/best-practices-baseline.json @@ -0,0 +1 @@ +{"issues":[{"type":"get_subprojects","name":"getSubprojects","trace":{"trace":[{"owner":"slack/gradle/artifacts/Resolver$Companion","name":"interProjectResolver$default","descriptor":"(Lslack/gradle/artifacts/Resolver$Companion;Lorg/gradle/api/Project;Lorg/gradle/api/attributes/Attribute;Ljava/io/Serializable;Ljava/lang/String;ZILjava/lang/Object;)Lslack/gradle/artifacts/Resolver;","metadata":{"isTaskAction":false,"isVirtual":false}},{"owner":"slack/gradle/artifacts/Resolver$Companion","name":"interProjectResolver","descriptor":"(Lorg/gradle/api/Project;Lorg/gradle/api/attributes/Attribute;Ljava/io/Serializable;Ljava/lang/String;Z)Lslack/gradle/artifacts/Resolver;","metadata":{"isTaskAction":false,"isVirtual":false}},{"owner":"slack/gradle/artifacts/Resolver","name":"addSubprojectDependencies","descriptor":"(Lorg/gradle/api/Project;)V","metadata":{"isTaskAction":false,"isVirtual":false}},{"owner":"org/gradle/api/Project","name":"getSubprojects","descriptor":"()Ljava/util/Set;","metadata":{"isTaskAction":false,"isVirtual":false}}]}},{"type":"get_subprojects","name":"getSubprojects","trace":{"trace":[{"owner":"slack/stats/ModuleStatsTasks$configureRoot$1$1","name":"call","descriptor":"()Ljava/lang/Object;","metadata":{"isTaskAction":false,"isVirtual":false}},{"owner":"slack/stats/ModuleStatsTasks$configureRoot$1$1","name":"call","descriptor":"()Ljava/util/Map;","metadata":{"isTaskAction":false,"isVirtual":false}},{"owner":"org/gradle/api/Project","name":"getSubprojects","descriptor":"()Ljava/util/Set;","metadata":{"isTaskAction":false,"isVirtual":false}}]}},{"type":"get_subprojects","name":"getSubprojects","trace":{"trace":[{"owner":"slack/gradle/SlackRootPlugin","name":"apply","descriptor":"(Ljava/lang/Object;)V","metadata":{"isTaskAction":false,"isVirtual":false}},{"owner":"slack/gradle/SlackRootPlugin","name":"apply","descriptor":"(Lorg/gradle/api/Project;)V","metadata":{"isTaskAction":false,"isVirtual":false}},{"owner":"slack/gradle/SlackRootPlugin","name":"configureRootProject","descriptor":"(Lorg/gradle/api/Project;Lslack/gradle/SlackProperties;Lorg/gradle/api/provider/Provider;)V","metadata":{"isTaskAction":false,"isVirtual":false}},{"owner":"slack/gradle/tasks/AndroidTestApksTask$Companion","name":"register$slack_plugin","descriptor":"(Lorg/gradle/api/Project;)Lorg/gradle/api/tasks/TaskProvider;","metadata":{"isTaskAction":false,"isVirtual":false}},{"owner":"slack/gradle/artifacts/Resolver$Companion","name":"interProjectResolver$default","descriptor":"(Lslack/gradle/artifacts/Resolver$Companion;Lorg/gradle/api/Project;Lslack/gradle/artifacts/SgpArtifact;ZILjava/lang/Object;)Lslack/gradle/artifacts/Resolver;","metadata":{"isTaskAction":false,"isVirtual":false}},{"owner":"slack/gradle/artifacts/Resolver$Companion","name":"interProjectResolver","descriptor":"(Lorg/gradle/api/Project;Lslack/gradle/artifacts/SgpArtifact;Z)Lslack/gradle/artifacts/Resolver;","metadata":{"isTaskAction":false,"isVirtual":false}},{"owner":"slack/gradle/artifacts/Resolver$Companion","name":"interProjectResolver","descriptor":"(Lorg/gradle/api/Project;Lorg/gradle/api/attributes/Attribute;Ljava/io/Serializable;Ljava/lang/String;Z)Lslack/gradle/artifacts/Resolver;","metadata":{"isTaskAction":false,"isVirtual":false}},{"owner":"slack/gradle/artifacts/Resolver","name":"addSubprojectDependencies","descriptor":"(Lorg/gradle/api/Project;)V","metadata":{"isTaskAction":false,"isVirtual":false}},{"owner":"org/gradle/api/Project","name":"getSubprojects","descriptor":"()Ljava/util/Set;","metadata":{"isTaskAction":false,"isVirtual":false}}]}},{"type":"get_subprojects","name":"getSubprojects","trace":{"trace":[{"owner":"slack/gradle/SlackRootPlugin$configureRootProject$10","name":"execute","descriptor":"(Ljava/lang/Object;)V","metadata":{"isTaskAction":false,"isVirtual":false}},{"owner":"slack/gradle/SlackRootPlugin$configureRootProject$10","name":"execute","descriptor":"(Lorg/gradle/api/plugins/AppliedPlugin;)V","metadata":{"isTaskAction":false,"isVirtual":false}},{"owner":"slack/dependencyrake/MissingIdentifiersAggregatorTask$Companion","name":"register","descriptor":"(Lorg/gradle/api/Project;)Lorg/gradle/api/tasks/TaskProvider;","metadata":{"isTaskAction":false,"isVirtual":false}},{"owner":"slack/gradle/artifacts/Resolver$Companion","name":"interProjectResolver$default","descriptor":"(Lslack/gradle/artifacts/Resolver$Companion;Lorg/gradle/api/Project;Lslack/gradle/artifacts/SgpArtifact;ZILjava/lang/Object;)Lslack/gradle/artifacts/Resolver;","metadata":{"isTaskAction":false,"isVirtual":false}},{"owner":"slack/gradle/artifacts/Resolver$Companion","name":"interProjectResolver","descriptor":"(Lorg/gradle/api/Project;Lslack/gradle/artifacts/SgpArtifact;Z)Lslack/gradle/artifacts/Resolver;","metadata":{"isTaskAction":false,"isVirtual":false}},{"owner":"slack/gradle/artifacts/Resolver$Companion","name":"interProjectResolver","descriptor":"(Lorg/gradle/api/Project;Lorg/gradle/api/attributes/Attribute;Ljava/io/Serializable;Ljava/lang/String;Z)Lslack/gradle/artifacts/Resolver;","metadata":{"isTaskAction":false,"isVirtual":false}},{"owner":"slack/gradle/artifacts/Resolver","name":"addSubprojectDependencies","descriptor":"(Lorg/gradle/api/Project;)V","metadata":{"isTaskAction":false,"isVirtual":false}},{"owner":"org/gradle/api/Project","name":"getSubprojects","descriptor":"()Ljava/util/Set;","metadata":{"isTaskAction":false,"isVirtual":false}}]}}]} \ No newline at end of file diff --git a/slack-plugin/src/main/kotlin/slack/dependencyrake/DependencyRake.kt b/slack-plugin/src/main/kotlin/slack/dependencyrake/DependencyRake.kt index bfb6f7d58..410189dbf 100644 --- a/slack-plugin/src/main/kotlin/slack/dependencyrake/DependencyRake.kt +++ b/slack-plugin/src/main/kotlin/slack/dependencyrake/DependencyRake.kt @@ -43,6 +43,8 @@ import org.gradle.api.tasks.PathSensitivity import org.gradle.api.tasks.TaskAction import org.gradle.api.tasks.TaskProvider import org.gradle.api.tasks.UntrackedTask +import slack.gradle.artifacts.Resolver +import slack.gradle.artifacts.SgpArtifact import slack.gradle.convertProjectPathToAccessor import slack.gradle.property import slack.gradle.util.mapToBoolean @@ -459,7 +461,10 @@ internal abstract class MissingIdentifiersAggregatorTask : DefaultTask() { const val NAME = "aggregateMissingIdentifiers" fun register(rootProject: Project): TaskProvider { + val resolver = + Resolver.interProjectResolver(rootProject, SgpArtifact.DAGP_MISSING_IDENTIFIERS) return rootProject.tasks.register(NAME, MissingIdentifiersAggregatorTask::class.java) { + inputFiles.from(resolver.artifactView()) outputFile.set( rootProject.layout.buildDirectory.file("rake/aggregated_missing_identifiers.txt") ) diff --git a/slack-plugin/src/main/kotlin/slack/gradle/GlobalConfig.kt b/slack-plugin/src/main/kotlin/slack/gradle/GlobalConfig.kt index a819bab24..32fcc406f 100644 --- a/slack-plugin/src/main/kotlin/slack/gradle/GlobalConfig.kt +++ b/slack-plugin/src/main/kotlin/slack/gradle/GlobalConfig.kt @@ -16,14 +16,11 @@ package slack.gradle import org.gradle.api.Project -import org.gradle.api.tasks.TaskProvider import org.gradle.jvm.toolchain.JvmVendorSpec -import slack.gradle.tasks.robolectric.UpdateRobolectricJarsTask /** Registry of global configuration info. */ public class GlobalConfig private constructor( - internal val updateRobolectricJarsTask: TaskProvider?, internal val kotlinDaemonArgs: List, internal val errorProneCheckNamesAsErrors: List, internal val affectedProjects: Set?, @@ -34,10 +31,7 @@ private constructor( operator fun invoke(project: Project): GlobalConfig { check(project == project.rootProject) { "Project is not root project!" } val globalSlackProperties = SlackProperties(project) - val robolectricJarsDownloadTask = - project.createRobolectricJarsDownloadTask(globalSlackProperties) return GlobalConfig( - updateRobolectricJarsTask = robolectricJarsDownloadTask, kotlinDaemonArgs = globalSlackProperties.kotlinDaemonArgs.split(" "), errorProneCheckNamesAsErrors = globalSlackProperties.errorProneCheckNamesAsErrors?.split(":").orEmpty(), @@ -63,18 +57,3 @@ private constructor( } } } - -private fun Project.createRobolectricJarsDownloadTask( - slackProperties: SlackProperties -): TaskProvider? { - if (slackProperties.versions.robolectric == null) { - // Not enabled - return null - } - - check(isRootProject) { - "Robolectric jars task should only be created once on the root project. Tried to apply on $name" - } - - return UpdateRobolectricJarsTask.register(this, slackProperties) -} diff --git a/slack-plugin/src/main/kotlin/slack/gradle/GradleExt.kt b/slack-plugin/src/main/kotlin/slack/gradle/GradleExt.kt index d12321b09..40bbaee5b 100644 --- a/slack-plugin/src/main/kotlin/slack/gradle/GradleExt.kt +++ b/slack-plugin/src/main/kotlin/slack/gradle/GradleExt.kt @@ -321,3 +321,13 @@ internal inline fun Project.serviceOf(): T = (this as ProjectInternal).services.get() internal inline fun ServiceRegistry.get(): T = this[T::class.java]!! + +@Suppress("UNCHECKED_CAST") +internal inline fun TaskContainer.registerOrConfigure( + taskName: String, + crossinline configureAction: T.() -> Unit +): TaskProvider = + when (taskName) { + in names -> named(taskName) as TaskProvider + else -> register(taskName, T::class.java) + }.apply { configure { configureAction() } } diff --git a/slack-plugin/src/main/kotlin/slack/gradle/SlackBasePlugin.kt b/slack-plugin/src/main/kotlin/slack/gradle/SlackBasePlugin.kt index e96da017e..a180bd560 100644 --- a/slack-plugin/src/main/kotlin/slack/gradle/SlackBasePlugin.kt +++ b/slack-plugin/src/main/kotlin/slack/gradle/SlackBasePlugin.kt @@ -25,7 +25,6 @@ import org.gradle.api.Project import org.gradle.api.artifacts.Configuration import org.gradle.api.artifacts.MinimalExternalModuleDependency import org.gradle.api.provider.Provider -import slack.gradle.tasks.CoreBootstrapTask import slack.stats.ModuleStatsTasks /** @@ -43,7 +42,6 @@ internal class SlackBasePlugin : Plugin { target.getVersionsCatalogOrNull() ?: error("SGP requires use of version catalogs!") val slackTools = target.slackTools() StandardProjectConfigurations(slackProperties, versionCatalog, slackTools).applyTo(target) - CoreBootstrapTask.configureSubprojectBootstrapTasks(target) // Configure Gradle's test-retry plugin for insights on build scans on CI only // Thinking here is that we don't want them to retry when iterating since failure diff --git a/slack-plugin/src/main/kotlin/slack/gradle/SlackRootPlugin.kt b/slack-plugin/src/main/kotlin/slack/gradle/SlackRootPlugin.kt index d06cba055..0d7db7d30 100644 --- a/slack-plugin/src/main/kotlin/slack/gradle/SlackRootPlugin.kt +++ b/slack-plugin/src/main/kotlin/slack/gradle/SlackRootPlugin.kt @@ -42,6 +42,7 @@ import slack.gradle.tasks.InstallCommitHooksTask import slack.gradle.tasks.KtLintDownloadTask import slack.gradle.tasks.KtfmtDownloadTask import slack.gradle.tasks.SortDependenciesDownloadTask +import slack.gradle.tasks.robolectric.UpdateRobolectricJarsTask import slack.gradle.util.JsonTools import slack.gradle.util.Thermals import slack.gradle.util.ThermalsData @@ -92,6 +93,7 @@ internal class SlackRootPlugin @Inject constructor(private val buildFeatures: Bu } else { project.logger.debug("Skippy is disabled") } + SlackTools.register( project = project, logThermals = logThermals, @@ -145,6 +147,11 @@ internal class SlackRootPlugin @Inject constructor(private val buildFeatures: Bu UnitTests.configureRootProject(project) ModuleStatsTasks.configureRoot(project, slackProperties) ComputeAffectedProjectsTask.register(project, slackProperties) + // Register robolectric jar downloads if requested + slackProperties.versions.robolectric?.let { + UpdateRobolectricJarsTask.register(project, slackProperties) + } + val scanApi = ScanApi(project) if (slackProperties.applyCommonBuildTags) { project.configureBuildScanMetadata(scanApi) diff --git a/slack-plugin/src/main/kotlin/slack/gradle/StandardProjectConfigurations.kt b/slack-plugin/src/main/kotlin/slack/gradle/StandardProjectConfigurations.kt index 705055dec..9c47fde52 100644 --- a/slack-plugin/src/main/kotlin/slack/gradle/StandardProjectConfigurations.kt +++ b/slack-plugin/src/main/kotlin/slack/gradle/StandardProjectConfigurations.kt @@ -57,12 +57,11 @@ import org.jetbrains.kotlin.gradle.dsl.kotlinExtension import org.jetbrains.kotlin.gradle.internal.KaptGenerateStubsTask import org.jetbrains.kotlin.gradle.plugin.KaptExtension import org.jetbrains.kotlin.gradle.plugin.KotlinBasePlugin -import org.jetbrains.kotlin.gradle.utils.named -import slack.dependencyrake.MissingIdentifiersAggregatorTask import slack.dependencyrake.RakeDependencies import slack.gradle.AptOptionsConfig.AptOptionsConfigurer import slack.gradle.AptOptionsConfigs.invoke -import slack.gradle.avoidance.ComputeAffectedProjectsTask +import slack.gradle.artifacts.Publisher +import slack.gradle.artifacts.SgpArtifact import slack.gradle.dependencies.BuildConfig import slack.gradle.dependencies.SlackDependencies import slack.gradle.lint.DetektTasks @@ -70,6 +69,9 @@ import slack.gradle.lint.LintTasks import slack.gradle.permissionchecks.PermissionChecks import slack.gradle.tasks.AndroidTestApksTask import slack.gradle.tasks.CheckManifestPermissionsTask +import slack.gradle.tasks.SimpleFileProducerTask +import slack.gradle.tasks.publishWith +import slack.gradle.tasks.robolectric.UpdateRobolectricJarsTask import slack.gradle.util.booleanProperty import slack.gradle.util.configureKotlinCompilationTask import slack.gradle.util.setDisallowChanges @@ -231,13 +233,9 @@ internal class StandardProjectConfigurations( configure { registerPostProcessingTask(rakeDependencies) } - val aggregator = - project.rootProject.tasks.named( - MissingIdentifiersAggregatorTask.NAME - ) - aggregator.configure { - inputFiles.from(rakeDependencies.flatMap { it.missingIdentifiersFile }) - } + val publisher = + Publisher.interProjectPublisher(project, SgpArtifact.DAGP_MISSING_IDENTIFIERS) + publisher.publish(rakeDependencies.flatMap { it.missingIdentifiersFile }) } } } @@ -449,16 +447,13 @@ internal class StandardProjectConfigurations( slackProperties: SlackProperties, ) { val javaVersion = JavaVersion.toVersion(jvmTargetVersion) - val computeAffectedProjectsTask = - project.rootProject.tasks.named( - ComputeAffectedProjectsTask.NAME, - ComputeAffectedProjectsTask::class.java - ) // Contribute these libraries to Fladle if they opt into it - val androidTestApksAggregator = - project.rootProject.tasks.named(AndroidTestApksTask.NAME, AndroidTestApksTask::class.java) + val androidTestApksPublisher = + Publisher.interProjectPublisher(project, SgpArtifact.ANDROID_TEST_APK_DIRS) val projectPath = project.path val isAffectedProject = slackTools.globalConfig.affectedProjects?.contains(projectPath) ?: true + val skippyAndroidTestProjectPublisher = + Publisher.interProjectPublisher(project, SgpArtifact.SKIPPY_ANDROID_TEST_PROJECT) val commonComponentsExtension = Action> { @@ -505,12 +500,22 @@ internal class StandardProjectConfigurations( val isAndroidTestEnabled = variant is HasAndroidTest && variant.androidTest != null if (isAndroidTestEnabled) { if (!excluded && isAffectedProject) { - computeAffectedProjectsTask.configure { androidTestProjects.add(projectPath) } + // Note this intentionally just uses the same task each time as they always produce + // the same output + SimpleFileProducerTask.registerOrConfigure( + project, + name = "androidTestProjectMetadata", + description = + "Produces a metadata artifact indicating this project path produces an androidTest APK.", + input = projectPath, + group = "skippy" + ) + .publishWith(skippyAndroidTestProjectPublisher) if (isLibraryVariant) { (variant as LibraryVariant).androidTest?.artifacts?.get(SingleArtifact.APK)?.let { apkArtifactsDir -> - // Wire this up to the aggregator - androidTestApksAggregator.configure { androidTestApkDirs.from(apkArtifactsDir) } + // Wire this up to the aggregator. No need for an intermediate task here. + androidTestApksPublisher.publishDirs(apkArtifactsDir) } } } else { @@ -588,12 +593,12 @@ internal class StandardProjectConfigurations( // // Note that we can't configure this to _just_ be enabled for robolectric projects // based on dependencies unfortunately, as the task graph is already wired by the - // time - // dependencies start getting resolved. + // time dependencies start getting resolved. // - slackTools.globalConfig.updateRobolectricJarsTask?.let { + slackProperties.versions.robolectric?.let { logger.debug("Configuring $name test task to depend on Robolectric jar downloads") - test.dependsOn(it) + // Depending on the root project task by name alone is ok for Project Isolation + test.dependsOn(UpdateRobolectricJarsTask.NAME) } // Necessary for some OkHttp-using tests to work on JDK 11 in Robolectric diff --git a/slack-plugin/src/main/kotlin/slack/gradle/artifacts/Publisher.kt b/slack-plugin/src/main/kotlin/slack/gradle/artifacts/Publisher.kt new file mode 100644 index 000000000..49ebbe316 --- /dev/null +++ b/slack-plugin/src/main/kotlin/slack/gradle/artifacts/Publisher.kt @@ -0,0 +1,125 @@ +/* + * Copyright (C) 2024. Tony Robalik. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package slack.gradle.artifacts + +import java.io.Serializable +import org.gradle.api.NamedDomainObjectProvider +import org.gradle.api.Project +import org.gradle.api.artifacts.Configuration +import org.gradle.api.attributes.Attribute +import org.gradle.api.file.Directory +import org.gradle.api.file.RegularFile +import org.gradle.api.provider.Provider + +/** + * Used for publishing custom artifacts from a subproject to an aggregating project (often the + * "root" project). Only for inter-project publishing (e.g., _not_ for publishing to Artifactory). + * See also [Resolver]. + * + * Represents a set of tightly coupled [Configuration]s: + * * A "dependency scope" configuration ([Resolver.declarable]). + * * A "resolvable" configuration ([Resolver.internal]). + * * A "consumable" configuration ([external]). + * + * Dependencies are _declared_ on [Resolver.declarable] in the aggregating project. Custom artifacts + * (e.g., not jars), generated by tasks, are published via [publish], which should be used on + * dependency (artifact-producing) projects. + * + * Gradle uses [attributes][ShareableArtifact.attribute] to wire the consumer project's + * [Resolver.internal] (resolvable) configuration to the producer project's [external] (consumable) + * configuration, which is itself configured via [publish]. + * + * @see Variant-aware + * sharing of artifacts between projects + * @see Gradle + * configuration roles + * @see Publisher.kt + */ +internal class Publisher( + project: Project, + attr: Attribute, + artifact: T, + declarableName: String, +) { + + companion object { + /** + * Convenience function for creating a [Publisher] for inter-project publishing of + * [SgpArtifact]. + */ + fun interProjectPublisher(project: Project, sgpArtifact: SgpArtifact) = + interProjectPublisher( + project, + sgpArtifact.attribute, + sgpArtifact, + sgpArtifact.declarableName, + ) + + fun > interProjectPublisher( + project: Project, + attr: Attribute, + artifact: T, + declarableName: String, + ): Publisher { + project.logger.debug("Creating publisher for $artifact") + return Publisher( + project, + attr, + artifact, + declarableName, + ) + } + } + + // Following the naming pattern established by the Java Library plugin. + // See + // https://docs.gradle.org/current/userguide/java_library_plugin.html#sec:java_library_configurations_graph + private val externalName = "${declarableName}Elements" + + /** + * The plugin will expose dependencies on this configuration, which extends from the declared + * dependencies. + */ + private val external: NamedDomainObjectProvider = run { + if (project.configurations.findByName(externalName) != null) { + project.configurations.named(externalName) + } else { + project.configurations.consumable(externalName) { + // This attribute is identical to what is set on the internal/resolvable configuration + attributes { attribute(attr, artifact) } + } + } + } + + /** + * Teach Gradle which thing produces the artifact associated with the external/consumable + * configuration. + */ + fun publish(output: Provider) { + external.configure { outgoing.artifact(output) } + } + + /** + * Teach Gradle which thing produces the artifact associated with the external/consumable + * configuration. + */ + fun publishDirs(output: Provider) { + external.configure { outgoing.artifact(output) } + } +} diff --git a/slack-plugin/src/main/kotlin/slack/gradle/artifacts/Resolver.kt b/slack-plugin/src/main/kotlin/slack/gradle/artifacts/Resolver.kt new file mode 100644 index 000000000..f8d3e1254 --- /dev/null +++ b/slack-plugin/src/main/kotlin/slack/gradle/artifacts/Resolver.kt @@ -0,0 +1,163 @@ +/* + * Copyright (C) 2024. Tony Robalik. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package slack.gradle.artifacts + +import java.io.File +import java.io.Serializable +import org.gradle.api.NamedDomainObjectProvider +import org.gradle.api.Project +import org.gradle.api.artifacts.Configuration +import org.gradle.api.attributes.Attribute +import org.gradle.api.provider.Provider + +/** + * Used for resolving custom artifacts in an aggregating project (often the "root" project), from + * producing projects (often all or a subset of the subprojects ina build). Only for inter-project + * publishing and resolving (e.g., _not_ for publishing to Artifactory). See also [Publisher]. + * + * Represents a set of tightly coupled [Configuration]s: + * * A "dependency scope" configuration ([declarable]). + * * A "resolvable" configuration ([internal]). + * * A "consumable" configuration ([Publisher.external]). + * + * Dependencies are _declared_ on [declarable], and resolved within a project via [internal]. Custom + * artifacts (e.g., not jars), generated by tasks, are published via [Publisher.publish], which + * should be used on dependency (artifact-producing) projects. + * + * Gradle uses [attributes][ShareableArtifact.attribute] to wire the consumer project's [internal] + * (resolvable) configuration to the producer project's [Publisher.external] (consumable) + * configuration, which is itself configured via [Publisher.publish]. + * + * @see Variant-aware + * sharing of artifacts between projects + * @see Gradle + * configuration roles + * @see Resolver.kt + */ +internal class Resolver( + project: Project, + private val attr: Attribute, + private val artifact: T, + declarableName: String, +) { + + internal companion object { + /** + * Convenience function for creating a [Resolver] for inter-project resolving of [SgpArtifact]. + */ + fun interProjectResolver( + project: Project, + artifact: SgpArtifact, + addDependencies: Boolean = true, + ) = + interProjectResolver( + project, + artifact.attribute, + artifact, + artifact.declarableName, + addDependencies + ) + + fun interProjectResolver( + project: Project, + attr: Attribute, + artifact: T, + declarableName: String, + addDependencies: Boolean = true, + ): Resolver { + project.logger.debug("Creating resolver for $artifact") + val resolver = + Resolver( + project, + attr, + artifact, + declarableName, + ) + if (addDependencies) { + project.logger.debug("Adding subproject dependencies to $artifact via $declarableName") + resolver.addSubprojectDependencies(project) + } + return resolver + } + } + + // Following the naming pattern established by the Java Library plugin. See + // https://docs.gradle.org/current/userguide/java_library_plugin.html#sec:java_library_configurations_graph + private val internalName = "${declarableName}Classpath" + + /** Dependencies are declared on this configuration */ + val declarable: Configuration = project.configurations.dependencyScope(declarableName).get() + + /** + * The plugin will resolve dependencies against this internal configuration, which extends from + * the declared dependencies. + */ + val internal: NamedDomainObjectProvider = + project.configurations.resolvable(internalName) { + extendsFrom(declarable) + // This attribute is identical to what is set on the external/consumable configuration + attributes { attribute(attr, artifact) } + } + + fun artifactView(): Provider> = artifactView(internal, attr, artifact) + + fun addSubprojectDependencies(project: Project) { + project.dependencies.apply { + for (subproject in project.subprojects) { + // Ignore subprojects that don't have a build file. Gradle treats these as projects but we + // don't. + // Accessing the projectDir _should_ be project-isolation-safe according to + // https://gradle.github.io/configuration-cache/#build_logic_constraints + val projectDir = subproject.projectDir + if ( + !File(projectDir, "build.gradle.kts").exists() && + !File(projectDir, "build.gradle").exists() + ) { + continue + } + add(declarable.name, project.project(subproject.path)) + } + } + } +} + +// Extracted to a function to make it harder to accidentally capture non-serializable values +private fun artifactView( + provider: NamedDomainObjectProvider, + attr: Attribute, + artifact: T +): Provider> { + return provider.flatMap { configuration -> + configuration.incoming + .artifactView { attributes { attribute(attr, artifact) } } + .artifacts + .resolvedArtifacts + .map { resolvedArtifactResults -> + resolvedArtifactResults.mapNotNullTo(mutableSetOf()) { + // Inexplicably, Gradle sometimes gives us random files that don't match the attribute we + // asked for. As a result, we need to add our own filter for the attribute. + if (it.variant.attributes.getAttribute(attr) == artifact) { + it.file + } else { + null + } + } + } + } +} diff --git a/slack-plugin/src/main/kotlin/slack/gradle/artifacts/SgpArtifact.kt b/slack-plugin/src/main/kotlin/slack/gradle/artifacts/SgpArtifact.kt new file mode 100644 index 000000000..f899295d6 --- /dev/null +++ b/slack-plugin/src/main/kotlin/slack/gradle/artifacts/SgpArtifact.kt @@ -0,0 +1,63 @@ +/* + * Copyright (C) 2024 Slack Technologies, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package slack.gradle.artifacts + +import org.gradle.api.attributes.Attribute + +internal sealed class SgpArtifact( + override val declarableName: String, +) : ShareableArtifact { + final override val attribute: Attribute + get() = SGP_ARTIFACTS_ATTRIBUTE + + companion object { + @JvmField + val SGP_ARTIFACTS_ATTRIBUTE: Attribute = + Attribute.of("sgp.internal.artifacts", SgpArtifact::class.java) + } + + data object SKIPPY_UNIT_TESTS : SgpArtifact("skippyUnitTests") { + private fun readResolve(): Any = SKIPPY_UNIT_TESTS + } + + data object SKIPPY_LINT : SgpArtifact("skippyLint") { + private fun readResolve(): Any = SKIPPY_LINT + } + + data object SKIPPY_AVOIDED_TASKS : SgpArtifact("skippyAvoidedTasks") { + private fun readResolve(): Any = SKIPPY_AVOIDED_TASKS + } + + data object SKIPPY_ANDROID_TEST_PROJECT : SgpArtifact("skippyAndroidTestProject") { + private fun readResolve(): Any = SKIPPY_ANDROID_TEST_PROJECT + } + + data object SKIPPY_DETEKT : SgpArtifact("skippyDetekt") { + private fun readResolve(): Any = SKIPPY_DETEKT + } + + data object ANDROID_TEST_APK_DIRS : SgpArtifact("androidTestApkDirs") { + private fun readResolve(): Any = ANDROID_TEST_APK_DIRS + } + + data object DAGP_MISSING_IDENTIFIERS : SgpArtifact("dagpMissingIdentifiers") { + private fun readResolve(): Any = DAGP_MISSING_IDENTIFIERS + } + + data object MOD_STATS_STATS_FILES : SgpArtifact("modStatsFiles") { + private fun readResolve(): Any = MOD_STATS_STATS_FILES + } +} diff --git a/slack-plugin/src/main/kotlin/slack/gradle/artifacts/ShareableArtifact.kt b/slack-plugin/src/main/kotlin/slack/gradle/artifacts/ShareableArtifact.kt new file mode 100644 index 000000000..828da7235 --- /dev/null +++ b/slack-plugin/src/main/kotlin/slack/gradle/artifacts/ShareableArtifact.kt @@ -0,0 +1,24 @@ +/* + * Copyright (C) 2024 Slack Technologies, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package slack.gradle.artifacts + +import java.io.Serializable +import org.gradle.api.attributes.Attribute + +internal interface ShareableArtifact> : Serializable { + val attribute: Attribute + val declarableName: String +} diff --git a/slack-plugin/src/main/kotlin/slack/gradle/avoidance/ComputeAffectedProjectsTask.kt b/slack-plugin/src/main/kotlin/slack/gradle/avoidance/ComputeAffectedProjectsTask.kt index 276173c79..82fea27cf 100644 --- a/slack-plugin/src/main/kotlin/slack/gradle/avoidance/ComputeAffectedProjectsTask.kt +++ b/slack-plugin/src/main/kotlin/slack/gradle/avoidance/ComputeAffectedProjectsTask.kt @@ -27,19 +27,23 @@ import okio.Path.Companion.toOkioPath import org.gradle.api.DefaultTask import org.gradle.api.NamedDomainObjectContainer import org.gradle.api.Project +import org.gradle.api.file.ConfigurableFileCollection import org.gradle.api.file.DirectoryProperty import org.gradle.api.provider.Property -import org.gradle.api.provider.SetProperty import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputFiles import org.gradle.api.tasks.Internal import org.gradle.api.tasks.OutputDirectory +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity import org.gradle.api.tasks.TaskAction import org.gradle.api.tasks.TaskProvider import org.gradle.api.tasks.UntrackedTask import org.gradle.api.tasks.options.Option import slack.gradle.SlackProperties +import slack.gradle.artifacts.Resolver +import slack.gradle.artifacts.SgpArtifact import slack.gradle.property -import slack.gradle.setProperty import slack.gradle.util.SgpLogger import slack.gradle.util.setDisallowChanges @@ -70,9 +74,13 @@ public abstract class ComputeAffectedProjectsTask : DefaultTask() { public val computeInParallel: Property = project.objects.property().convention(true) - @get:Input - public val androidTestProjects: SetProperty = - project.objects.setProperty().convention(emptySet()) + /** + * Consumed artifacts of project paths that produce androidTest APKs. Each file will just have one + * line that contains a project path. + */ + @get:PathSensitive(PathSensitivity.RELATIVE) + @get:InputFiles + public abstract val androidTestProjectInputs: ConfigurableFileCollection /** * A relative (to the repo root) path to a changed_files.txt that contains a newline-delimited @@ -109,13 +117,14 @@ public abstract class ComputeAffectedProjectsTask : DefaultTask() { } else { 1 } + val androidTestProjects = androidTestProjectInputs.map { it.readText().trim() }.toSet() val body: suspend (context: CoroutineContext) -> Unit = { context -> SkippyRunner( debug = debug.get(), logger = SgpLogger.gradle(logger), mergeOutputs = mergeOutputs.get(), outputsDir = outputsDir.get().asFile.toOkioPath(), - androidTestProjects = androidTestProjects.get(), + androidTestProjects = androidTestProjects, rootDir = rootDirPath, parallelism = parallelism, fs = FileSystem.SYSTEM, @@ -179,6 +188,12 @@ public abstract class ComputeAffectedProjectsTask : DefaultTask() { GradleDependencyGraphFactory.create(rootProject, configurationsToLook).serializableGraph() } + val androidTestApksResolver = + Resolver.interProjectResolver( + rootProject, + SgpArtifact.SKIPPY_ANDROID_TEST_PROJECT, + ) + return rootProject.tasks.register(NAME, ComputeAffectedProjectsTask::class.java) { debug.setDisallowChanges(extension.debug) mergeOutputs.setDisallowChanges(extension.mergeOutputs) @@ -187,6 +202,7 @@ public abstract class ComputeAffectedProjectsTask : DefaultTask() { rootDir.setDisallowChanges(project.layout.projectDirectory) dependencyGraph.setDisallowChanges(rootProject.provider { moduleGraph }) outputsDir.setDisallowChanges(project.layout.buildDirectory.dir("skippy")) + androidTestProjectInputs.from(androidTestApksResolver.artifactView()) // Overrides of includes/neverSkippable patterns should be done in the consuming project // directly } diff --git a/slack-plugin/src/main/kotlin/slack/gradle/avoidance/SkippyArtifacts.kt b/slack-plugin/src/main/kotlin/slack/gradle/avoidance/SkippyArtifacts.kt new file mode 100644 index 000000000..3945b17f9 --- /dev/null +++ b/slack-plugin/src/main/kotlin/slack/gradle/avoidance/SkippyArtifacts.kt @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2024 Slack Technologies, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package slack.gradle.avoidance + +import org.gradle.api.Project +import slack.gradle.artifacts.Publisher +import slack.gradle.artifacts.SgpArtifact +import slack.gradle.capitalizeUS +import slack.gradle.tasks.SimpleFileProducerTask +import slack.gradle.tasks.publishWith + +internal object SkippyArtifacts { + fun publishSkippedTask(project: Project, name: String) { + SimpleFileProducerTask.registerOrConfigure( + project, + name = "skipped${name.capitalizeUS()}", + description = "Lifecycle task to run unit tests for ${project.path} (skipped).", + ) + .publishWith(Publisher.interProjectPublisher(project, SgpArtifact.SKIPPY_AVOIDED_TASKS)) + } +} diff --git a/slack-plugin/src/main/kotlin/slack/gradle/lint/DetektTasks.kt b/slack-plugin/src/main/kotlin/slack/gradle/lint/DetektTasks.kt index 40eb72617..5e4582280 100644 --- a/slack-plugin/src/main/kotlin/slack/gradle/lint/DetektTasks.kt +++ b/slack-plugin/src/main/kotlin/slack/gradle/lint/DetektTasks.kt @@ -24,16 +24,24 @@ import org.gradle.api.file.Directory import org.gradle.api.specs.Spec import org.gradle.language.base.plugins.LifecycleBasePlugin import slack.gradle.SlackProperties +import slack.gradle.artifacts.Publisher +import slack.gradle.artifacts.Resolver +import slack.gradle.artifacts.SgpArtifact +import slack.gradle.avoidance.SkippyArtifacts import slack.gradle.configure import slack.gradle.configureEach import slack.gradle.isRootProject import slack.gradle.register import slack.gradle.tasks.DetektDownloadTask +import slack.gradle.tasks.SimpleFileProducerTask +import slack.gradle.tasks.SimpleFilesConsumerTask +import slack.gradle.tasks.publish import slack.gradle.util.setDisallowChanges import slack.gradle.util.sneakyNull internal object DetektTasks { private const val GLOBAL_CI_DETEKT_TASK_NAME = "globalCiDetekt" + private const val CI_DETEKT_TASK_NAME = "ciDetekt" private const val LOG = "SlackDetekt:" fun configureRootProject( @@ -47,10 +55,13 @@ internal object DetektTasks { outputFile.setDisallowChanges(project.layout.projectDirectory.file("config/bin/detekt")) } - project.tasks.register(GLOBAL_CI_DETEKT_TASK_NAME) { - group = LifecycleBasePlugin.VERIFICATION_GROUP - description = "Global lifecycle task to run all dependent detekt tasks." - } + val resolver = Resolver.interProjectResolver(project, SgpArtifact.SKIPPY_DETEKT) + SimpleFilesConsumerTask.registerOrConfigure( + project, + GLOBAL_CI_DETEKT_TASK_NAME, + description = "Global lifecycle task to run all dependent detekt tasks.", + inputFiles = resolver.artifactView(), + ) } } @@ -89,9 +100,9 @@ internal object DetektTasks { } } - val globalTask = + val publisher = if (affectedProjects == null || project.path in affectedProjects) { - project.rootProject.tasks.named(GLOBAL_CI_DETEKT_TASK_NAME) + Publisher.interProjectPublisher(project, SgpArtifact.SKIPPY_DETEKT) } else { val log = "$LOG Skipping ${project.path}:detekt because it is not affected." if (slackProperties.debug) { @@ -99,6 +110,7 @@ internal object DetektTasks { } else { project.logger.debug(log) } + SkippyArtifacts.publishSkippedTask(project, "detekt") null } @@ -117,19 +129,27 @@ internal object DetektTasks { } // Wire up to the global task - globalTask?.configure { - // We use a filter on Detekt tasks because not every project actually makes one! - val taskSpec = - if (slackProperties.enableFullDetekt) { - // Depend on all Detekt tasks with type resolution - // The "detekt" task is excluded because it is a plain, non-type-resolution version - Spec { it.name != DetektPlugin.DETEKT_TASK_NAME } - } else { - // Depend _only_ on the "detekt plain" task, which runs without type resolution - Spec { it.name == DetektPlugin.DETEKT_TASK_NAME } - } - dependsOn(project.tasks.withType(Detekt::class.java).matching(taskSpec)) - } + // We use a filter on Detekt tasks because not every project actually makes one! + val taskSpec = + if (slackProperties.enableFullDetekt) { + // Depend on all Detekt tasks with type resolution + // The "detekt" task is excluded because it is a plain, non-type-resolution version + Spec { it.name != DetektPlugin.DETEKT_TASK_NAME } + } else { + // Depend _only_ on the "detekt plain" task, which runs without type resolution + Spec { it.name == DetektPlugin.DETEKT_TASK_NAME } + } + val matchingTasks = project.tasks.withType(Detekt::class.java).matching(taskSpec) + val ciDetekt = + SimpleFileProducerTask.registerOrConfigure( + project, + CI_DETEKT_TASK_NAME, + description = "Lifecycle task to run detekt for ${project.path}.", + group = LifecycleBasePlugin.VERIFICATION_GROUP, + ) { + dependsOn(matchingTasks) + } + publisher?.publish(ciDetekt) } } } diff --git a/slack-plugin/src/main/kotlin/slack/gradle/lint/LintTasks.kt b/slack-plugin/src/main/kotlin/slack/gradle/lint/LintTasks.kt index cf57cce4e..727bf2b9f 100644 --- a/slack-plugin/src/main/kotlin/slack/gradle/lint/LintTasks.kt +++ b/slack-plugin/src/main/kotlin/slack/gradle/lint/LintTasks.kt @@ -27,7 +27,6 @@ import org.gradle.api.Action import org.gradle.api.GradleException import org.gradle.api.Plugin import org.gradle.api.Project -import org.gradle.api.Task import org.gradle.api.file.ConfigurableFileCollection import org.gradle.api.plugins.JavaPlugin import org.gradle.api.plugins.JavaPluginExtension @@ -39,9 +38,16 @@ import org.jetbrains.kotlin.tooling.core.withClosure import slack.gradle.SlackProperties import slack.gradle.androidExtension import slack.gradle.androidExtensionNullable +import slack.gradle.artifacts.Publisher +import slack.gradle.artifacts.Resolver +import slack.gradle.artifacts.SgpArtifact +import slack.gradle.avoidance.SkippyArtifacts import slack.gradle.capitalizeUS import slack.gradle.getByType import slack.gradle.multiplatformExtension +import slack.gradle.tasks.SimpleFileProducerTask +import slack.gradle.tasks.SimpleFilesConsumerTask +import slack.gradle.tasks.publish /** * Common configuration for Android lint in projects. @@ -52,18 +58,21 @@ import slack.gradle.multiplatformExtension internal object LintTasks { private const val GLOBAL_CI_LINT_TASK_NAME = "globalCiLint" private const val CI_LINT_TASK_NAME = "ciLint" - private const val COMPILE_CI_LINT_NAME = "compileCiLint" private const val LOG = "SlackLints:" private fun Project.log(message: String) { logger.debug("$LOG $message") } - fun configureRootProject(project: Project): TaskProvider = - project.tasks.register(GLOBAL_CI_LINT_TASK_NAME) { - group = LifecycleBasePlugin.VERIFICATION_GROUP - description = "Global lifecycle task to run all dependent lint tasks." - } + fun configureRootProject(project: Project) { + val resolver = Resolver.interProjectResolver(project, SgpArtifact.SKIPPY_LINT) + SimpleFilesConsumerTask.registerOrConfigure( + project, + GLOBAL_CI_LINT_TASK_NAME, + description = "Global lifecycle task to run all ciUnitTest tasks.", + inputFiles = resolver.artifactView(), + ) + } fun configureSubProject( project: Project, @@ -142,8 +151,7 @@ internal object LintTasks { ) log("Creating ciLint task") - val ciLintTask = - tasks.register(CI_LINT_TASK_NAME) { group = LifecycleBasePlugin.VERIFICATION_GROUP } + val ciLintTask = createCiLintTask(project) val ciLintVariants = slackProperties.ciLintVariants if (ciLintVariants != null) { @@ -207,11 +215,7 @@ internal object LintTasks { } log("Creating ciLint task") - val ciLint = - tasks.register(COMPILE_CI_LINT_NAME) { - group = LifecycleBasePlugin.VERIFICATION_GROUP - dependsOn(lintTask) - } + val ciLint = createCiLintTask(project) { dependsOn(lintTask) } // For Android projects, we can run lint configuration last using `DslLifecycle.finalizeDsl`; // however, we need to run it using `Project.afterEvaluate` for non-Android projects. @@ -226,7 +230,7 @@ internal object LintTasks { private fun Project.configureLint( lint: Lint, - ciLintTask: TaskProvider<*>, + ciLint: TaskProvider, slackProperties: SlackProperties, affectedProjects: Set?, onProjectSkipped: (String, String) -> Unit, @@ -236,22 +240,20 @@ internal object LintTasks { slackProperties.versions.bundles.commonLint.ifPresent { dependencies.add("lintChecks", it) } - val globalTask = - if (affectedProjects == null || path in affectedProjects) { - rootProject.tasks.named(GLOBAL_CI_LINT_TASK_NAME) + if (affectedProjects != null && path !in affectedProjects) { + val taskPath = "${path}:$CI_LINT_TASK_NAME" + val log = "Skipping $taskPath because it is not affected." + onProjectSkipped(GLOBAL_CI_LINT_TASK_NAME, taskPath) + if (slackProperties.debug) { + log(log) } else { - val taskPath = "${path}:$CI_LINT_TASK_NAME" - val log = "Skipping $taskPath because it is not affected." - onProjectSkipped(GLOBAL_CI_LINT_TASK_NAME, taskPath) - if (slackProperties.debug) { - log(log) - } else { - log(log) - } - null + log(log) } - - globalTask?.configure { dependsOn(ciLintTask) } + SkippyArtifacts.publishSkippedTask(project, CI_LINT_TASK_NAME) + } else { + val publisher = Publisher.interProjectPublisher(project, SgpArtifact.SKIPPY_LINT) + publisher.publish(ciLint) + } afterEvaluate { addSourceSetsForAndroidMultiplatformAfterEvaluate() } @@ -435,4 +437,20 @@ internal object LintTasks { block() disallowChanges.set(this, true) } + + private fun createCiLintTask( + project: Project, + action: Action = Action {}, + ): TaskProvider { + project.logger.debug("Creating ciLint task: ${project.path}:$CI_LINT_TASK_NAME") + val task = + SimpleFileProducerTask.registerOrConfigure( + project, + CI_LINT_TASK_NAME, + group = LifecycleBasePlugin.VERIFICATION_GROUP, + description = "Lifecycle task to run all lint tasks on this project.", + action = action + ) + return task + } } diff --git a/slack-plugin/src/main/kotlin/slack/gradle/tasks/AndroidTestApksTask.kt b/slack-plugin/src/main/kotlin/slack/gradle/tasks/AndroidTestApksTask.kt index 59d9b3b24..c1a9b0722 100644 --- a/slack-plugin/src/main/kotlin/slack/gradle/tasks/AndroidTestApksTask.kt +++ b/slack-plugin/src/main/kotlin/slack/gradle/tasks/AndroidTestApksTask.kt @@ -15,6 +15,11 @@ */ package slack.gradle.tasks +import kotlin.io.path.ExperimentalPathApi +import kotlin.io.path.absolutePathString +import kotlin.io.path.extension +import kotlin.io.path.isRegularFile +import kotlin.io.path.walk import org.gradle.api.DefaultTask import org.gradle.api.Project import org.gradle.api.file.ConfigurableFileCollection @@ -25,6 +30,8 @@ import org.gradle.api.tasks.PathSensitive import org.gradle.api.tasks.PathSensitivity.RELATIVE import org.gradle.api.tasks.TaskAction import org.gradle.api.tasks.TaskProvider +import slack.gradle.artifacts.Resolver +import slack.gradle.artifacts.SgpArtifact import slack.gradle.register import slack.gradle.util.setDisallowChanges @@ -34,13 +41,6 @@ import slack.gradle.util.setDisallowChanges * * Not cacheable because this outputs absolute paths. */ -// TODO in the future, Gradle technically has a more "correct" mechanism for -// exposing information like this via "Outgoing Variants". This API is not remotely easy to grok -// so let's save implementing that for a day if/when we have multiple app targets in the Android -// repo that need to only run dependent libraries' test APKs. -// The Jacoco Report Aggregation Plugin is one such example of a plugin that contributes such -// metadata: https://docs.gradle.org/current/userguide/jacoco_plugin.html#sec:outgoing_variants -// Full docs: https://docs.gradle.org/current/userguide/cross_project_publications.html public abstract class AndroidTestApksTask : DefaultTask() { @get:PathSensitive(RELATIVE) @get:InputFiles @@ -52,6 +52,7 @@ public abstract class AndroidTestApksTask : DefaultTask() { group = "slack" } + @OptIn(ExperimentalPathApi::class) @TaskAction public fun writeFiles() { outputFile.asFile @@ -59,9 +60,9 @@ public abstract class AndroidTestApksTask : DefaultTask() { .writeText( androidTestApkDirs .asSequence() - .flatMap { it.walk() } - .filter { it.isFile && it.extension == "apk" } - .joinToString("\n") { apk -> "- test: ${apk.absolutePath}" } + .flatMap { it.toPath().walk() } + .filter { it.isRegularFile() && it.extension == "apk" } + .joinToString("\n") { apk -> "- test: ${apk.absolutePathString()}" } ) } @@ -69,7 +70,9 @@ public abstract class AndroidTestApksTask : DefaultTask() { public const val NAME: String = "aggregateAndroidTestApks" internal fun register(project: Project): TaskProvider { + val resolver = Resolver.interProjectResolver(project, SgpArtifact.ANDROID_TEST_APK_DIRS) return project.tasks.register(NAME) { + androidTestApkDirs.from(resolver.artifactView()) outputFile.setDisallowChanges( project.layout.buildDirectory.file("slack/androidTestAggregator/aggregatedTestApks.txt") ) diff --git a/slack-plugin/src/main/kotlin/slack/gradle/tasks/BootstrapTask.kt b/slack-plugin/src/main/kotlin/slack/gradle/tasks/BootstrapTask.kt index 00365a089..5921d3cf6 100644 --- a/slack-plugin/src/main/kotlin/slack/gradle/tasks/BootstrapTask.kt +++ b/slack-plugin/src/main/kotlin/slack/gradle/tasks/BootstrapTask.kt @@ -48,7 +48,6 @@ import org.gradle.jvm.toolchain.JavaLanguageVersion import org.gradle.jvm.toolchain.JavaLauncher import org.gradle.jvm.toolchain.JavaToolchainService import org.gradle.jvm.toolchain.JvmVendorSpec -import org.gradle.language.base.plugins.LifecycleBasePlugin import oshi.SystemInfo import slack.cli.AppleSiliconCompat import slack.cli.AppleSiliconCompat.isMacOS @@ -323,23 +322,6 @@ constructor(objects: ObjectFactory, providers: ProviderFactory) : DefaultTask() return project.gradle.startParameter.taskNames.any { it == NAME } } - internal fun configureSubprojectBootstrapTasks(project: Project) { - if (!isBootstrapEnabled(project)) return - val rootTask = project.rootProject.tasks.named(NAME, CoreBootstrapTask::class.java) - // Clever trick to make this finalized by all bootstrap tasks and all other tasks depend on - // this, so bootstrap always runs first. - project.tasks.configureEach { - val task = this - if (name == NAME) return@configureEach - if (name == LifecycleBasePlugin.CLEAN_TASK_NAME) return@configureEach - if (this is BootstrapTask) { - rootTask.configure { finalizedBy(task) } - } else { - dependsOn(rootTask) - } - } - } - public fun register( project: Project, jvmVendor: JvmVendorSpec? diff --git a/slack-plugin/src/main/kotlin/slack/gradle/tasks/FileTasks.kt b/slack-plugin/src/main/kotlin/slack/gradle/tasks/FileTasks.kt new file mode 100644 index 000000000..6a9e7b2d8 --- /dev/null +++ b/slack-plugin/src/main/kotlin/slack/gradle/tasks/FileTasks.kt @@ -0,0 +1,120 @@ +/* + * Copyright (C) 2024 Slack Technologies, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package slack.gradle.tasks + +import java.io.File +import org.gradle.api.Action +import org.gradle.api.DefaultTask +import org.gradle.api.Project +import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.Property +import org.gradle.api.provider.Provider +import org.gradle.api.tasks.CacheableTask +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputFiles +import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction +import org.gradle.api.tasks.TaskProvider +import slack.gradle.artifacts.Publisher +import slack.gradle.registerOrConfigure + +@CacheableTask +internal abstract class SimpleFileProducerTask : DefaultTask() { + @get:Input abstract val input: Property + + @get:OutputFile abstract val output: RegularFileProperty + + @TaskAction + fun writeText() { + val outputFile = output.get().asFile + outputFile.writeText(input.get()) + } + + companion object { + fun registerOrConfigure( + project: Project, + name: String, + description: String, + outputFilePath: String = "artifactMetadata/$name/produced.txt", + input: String = "${project.path}:$name", + group: String = "slack", + action: Action = Action {}, + ): TaskProvider { + return project.tasks.registerOrConfigure(name) { + this.group = group + this.description = description + this.input.set(input) + output.set(project.layout.buildDirectory.file(outputFilePath)) + action.execute(this) + } + } + } +} + +@CacheableTask +internal abstract class SimpleFilesConsumerTask : DefaultTask() { + @get:PathSensitive(PathSensitivity.RELATIVE) + @get:InputFiles + abstract val inputFiles: ConfigurableFileCollection + + @get:OutputFile abstract val output: RegularFileProperty + + @TaskAction + fun mergeFiles() { + val outputFile = output.get().asFile + outputFile.writeText( + inputFiles.files + .map { + logger.debug("Merging file: $it") + it.readText() + } + .sorted() + .joinToString("\n") + ) + } + + companion object { + fun registerOrConfigure( + project: Project, + name: String, + description: String, + inputFiles: Provider>, + outputFilePath: String = "artifactMetadata/$name/resolved.txt", + group: String = "slack", + action: Action = Action {}, + ): TaskProvider { + return project.tasks.registerOrConfigure(name) { + this.group = group + this.description = description + this.inputFiles.from(inputFiles) + output.set(project.layout.buildDirectory.file(outputFilePath)) + action.execute(this) + } + } + } +} + +internal fun Publisher<*>.publish(provider: TaskProvider) { + publish(provider.flatMap { it.output }) +} + +/** Inverse of the above [Publisher.publish] available for fluent calls. */ +internal fun TaskProvider.publishWith(publisher: Publisher<*>) { + publisher.publish(this) +} diff --git a/slack-plugin/src/main/kotlin/slack/gradle/tasks/robolectric/UpdateRobolectricJarsTask.kt b/slack-plugin/src/main/kotlin/slack/gradle/tasks/robolectric/UpdateRobolectricJarsTask.kt index 5befff61c..30ca125f7 100644 --- a/slack-plugin/src/main/kotlin/slack/gradle/tasks/robolectric/UpdateRobolectricJarsTask.kt +++ b/slack-plugin/src/main/kotlin/slack/gradle/tasks/robolectric/UpdateRobolectricJarsTask.kt @@ -89,6 +89,7 @@ internal abstract class UpdateRobolectricJarsTask : DefaultTask(), BootstrapTask companion object { private const val TAG = "[RobolectricJarsDownloadTask]" + internal const val NAME = "updateRobolectricJars" fun jarsIn(dir: File): Set { return dir.listFiles().orEmpty().filterTo(LinkedHashSet()) { it.extension == "jar" } @@ -98,7 +99,7 @@ internal abstract class UpdateRobolectricJarsTask : DefaultTask(), BootstrapTask project: Project, slackProperties: SlackProperties ): TaskProvider { - return project.tasks.register("updateRobolectricJars") { + return project.tasks.register(NAME) { val iVersion = slackProperties.robolectricIVersion for (sdkInt in slackProperties.robolectricTestSdks) { // Create a new configuration diff --git a/slack-plugin/src/main/kotlin/slack/stats/ModuleStats.kt b/slack-plugin/src/main/kotlin/slack/stats/ModuleStats.kt index 18a1a675b..5b48e4368 100644 --- a/slack-plugin/src/main/kotlin/slack/stats/ModuleStats.kt +++ b/slack-plugin/src/main/kotlin/slack/stats/ModuleStats.kt @@ -55,6 +55,9 @@ import org.jgrapht.graph.DefaultEdge import org.jgrapht.graph.DirectedAcyclicGraph import slack.gradle.SlackExtension import slack.gradle.SlackProperties +import slack.gradle.artifacts.Publisher +import slack.gradle.artifacts.Resolver +import slack.gradle.artifacts.SgpArtifact import slack.gradle.capitalizeUS import slack.gradle.configure import slack.gradle.convertProjectPathToAccessor @@ -85,8 +88,19 @@ public object ModuleStatsTasks { internal fun configureRoot(rootProject: Project, slackProperties: SlackProperties) { if (!slackProperties.modScoreGlobalEnabled) return val includeGenerated = rootProject.includeGenerated() + val resolver = Resolver.interProjectResolver(rootProject, SgpArtifact.MOD_STATS_STATS_FILES) rootProject.tasks.register(AGGREGATOR_NAME) { + projectPathsToAccessors.setDisallowChanges( + rootProject.provider { + rootProject.subprojects.associate { subproject -> + val regularPath = subproject.path + val projectAccessor = convertProjectPathToAccessor(regularPath) + projectAccessor to regularPath + } + } + ) + statsFiles.from(resolver.artifactView()) outputFile.setDisallowChanges( rootProject.layout.buildDirectory.file("reports/slack/moduleStats.json") ) @@ -143,14 +157,8 @@ public object ModuleStatsTasks { ) } - val aggregatorTask = - project.rootProject.tasks.named(AGGREGATOR_NAME, ModuleStatsAggregatorTask::class.java) - val regularPath = project.path - val projectAccessor = convertProjectPathToAccessor(regularPath) - aggregatorTask.configure { - projectPathsToAccessors.put(projectAccessor, regularPath) - statsFiles.from(task.map { it.outputFile }) - } + val publisher = Publisher.interProjectPublisher(project, SgpArtifact.MOD_STATS_STATS_FILES) + publisher.publish(task.flatMap { it.outputFile }) task } diff --git a/slack-plugin/src/main/kotlin/slack/unittest/UnitTests.kt b/slack-plugin/src/main/kotlin/slack/unittest/UnitTests.kt index 604e4c38d..94b0d37fa 100644 --- a/slack-plugin/src/main/kotlin/slack/unittest/UnitTests.kt +++ b/slack-plugin/src/main/kotlin/slack/unittest/UnitTests.kt @@ -19,16 +19,22 @@ import com.gradle.enterprise.gradleplugin.testretry.retry as geRetry import kotlin.math.max import kotlin.math.roundToInt import org.gradle.api.Project -import org.gradle.api.Task import org.gradle.api.tasks.TaskProvider import org.gradle.api.tasks.testing.Test import org.gradle.kotlin.dsl.retry import org.gradle.language.base.plugins.LifecycleBasePlugin import slack.gradle.SlackProperties +import slack.gradle.artifacts.Publisher +import slack.gradle.artifacts.Resolver +import slack.gradle.artifacts.SgpArtifact +import slack.gradle.avoidance.SkippyArtifacts import slack.gradle.ciUnitTestAndroidVariant import slack.gradle.configureEach import slack.gradle.isActionsCi import slack.gradle.isCi +import slack.gradle.tasks.SimpleFileProducerTask +import slack.gradle.tasks.SimpleFilesConsumerTask +import slack.gradle.tasks.publish import slack.gradle.util.setDisallowChanges import slack.gradle.util.synchronousEnvProperty @@ -61,11 +67,16 @@ internal object UnitTests { return max((Runtime.getRuntime().availableProcessors() * multiplier).roundToInt(), 1) } - fun configureRootProject(project: Project): TaskProvider = - project.tasks.register(GLOBAL_CI_UNIT_TEST_TASK_NAME) { - group = LifecycleBasePlugin.VERIFICATION_GROUP - description = "Global lifecycle task to run all ciUnitTest tasks." - } + fun configureRootProject(project: Project) { + val resolver = Resolver.interProjectResolver(project, SgpArtifact.SKIPPY_UNIT_TESTS) + SimpleFilesConsumerTask.registerOrConfigure( + project = project, + name = GLOBAL_CI_UNIT_TEST_TASK_NAME, + group = LifecycleBasePlugin.VERIFICATION_GROUP, + description = "Global lifecycle task to run all ciUnitTest tasks.", + inputFiles = resolver.artifactView(), + ) + } fun configureSubproject( project: Project, @@ -91,9 +102,9 @@ internal object UnitTests { } } - val globalTask = + val unitTestsPublisher: Publisher? = if (affectedProjects == null || project.path in affectedProjects) { - project.rootProject.tasks.named(GLOBAL_CI_UNIT_TEST_TASK_NAME) + Publisher.interProjectPublisher(project, SgpArtifact.SKIPPY_UNIT_TESTS) } else { val taskPath = "${project.path}:$CI_UNIT_TEST_TASK_NAME" onProjectSkipped(GLOBAL_CI_UNIT_TEST_TASK_NAME, taskPath) @@ -103,23 +114,20 @@ internal object UnitTests { } else { project.logger.debug(log) } + SkippyArtifacts.publishSkippedTask(project, CI_UNIT_TEST_TASK_NAME) null } when (pluginId) { "com.android.application", "com.android.library" -> { - createAndroidCiUnitTestTask(project, globalTask) + createAndroidCiUnitTestTask(project, unitTestsPublisher) } else -> { // Standard JVM projects like kotlin-jvm, java-library, etc project.logger.debug("$LOG Creating CI unit test tasks") - val ciUnitTest = - project.tasks.register(CI_UNIT_TEST_TASK_NAME) { - group = LifecycleBasePlugin.VERIFICATION_GROUP - dependsOn("test") - } - globalTask?.configure { dependsOn(ciUnitTest) } + val ciUnitTest = registerCiUnitTest(project, "test") + unitTestsPublisher?.publish(ciUnitTest) project.tasks.register(COMPILE_CI_UNIT_TEST_NAME) { group = LifecycleBasePlugin.VERIFICATION_GROUP dependsOn("testClasses") @@ -130,19 +138,16 @@ internal object UnitTests { configureTestTasks(project, slackProperties) } - private fun createAndroidCiUnitTestTask(project: Project, globalTask: TaskProvider<*>?) { + private fun createAndroidCiUnitTestTask( + project: Project, + unitTestsPublisher: Publisher? + ) { val variant = project.ciUnitTestAndroidVariant() val variantUnitTestTaskName = "test${variant}UnitTest" val variantCompileUnitTestTaskName = "compile${variant}UnitTestSources" project.logger.debug("$LOG Creating CI unit test tasks for variant '$variant'") - val ciUnitTest = - project.tasks.register(CI_UNIT_TEST_TASK_NAME) { - group = LifecycleBasePlugin.VERIFICATION_GROUP - // Even if the task isn't created yet, we can do this by name alone and it will resolve at - // task configuration time. - dependsOn(variantUnitTestTaskName) - } - globalTask?.configure { dependsOn(ciUnitTest) } + val ciUnitTest = registerCiUnitTest(project, variantUnitTestTaskName) + unitTestsPublisher?.publish(ciUnitTest) project.tasks.register(COMPILE_CI_UNIT_TEST_NAME) { group = LifecycleBasePlugin.VERIFICATION_GROUP // Even if the task isn't created yet, we can do this by name alone and it will resolve at @@ -259,4 +264,18 @@ internal object UnitTests { } } } + + private fun registerCiUnitTest( + project: Project, + dependencyTaskName: String, + ): TaskProvider { + return SimpleFileProducerTask.registerOrConfigure( + project, + name = CI_UNIT_TEST_TASK_NAME, + group = LifecycleBasePlugin.VERIFICATION_GROUP, + description = "Lifecycle task to run unit tests for ${project.path}.", + ) { + dependsOn(dependencyTaskName) + } + } }