diff --git a/README.md b/README.md index 28739a32..8d726eb3 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,13 @@ on how to do that, including how to develop and test locally and the versioning *Released*: TBD (Earliest compatible LabKey version: 26.6.0) - Pass all command-line 'webtest' and 'webdriver' properties to tests (fix to work off of TeamCity) +- Convert `verifyLicensePatch`, `patchApiModule`, `deployModule`, `undeployModule`, `symlinkNode`, `undeployModules` to configuration-cache compatible tasks +- Update `ModuleFinder` to not use deprecated `hasProperty` check that looks in parent project +- Update `ModuleDistribution` and `RunUiTest` to be compatible with the configuration cache +- Updates to `MultiGit` to mark as not configuration-cache compatible +- Convert tasks that write out startup properties in `TeamCity` plugin to be compatible with the configuration cache +- Update `TestRunner`'s `compileUITestJava` task to be config-cache compatible and move the declaration of the `aspectj` configuration to that plugin +- Remove `undeployModulesNotForX` task from the `TeamCity` plugin ### 9.2.0 *Released*: 27 July 2026 diff --git a/build.gradle b/build.gradle index ecdc96f5..55de6ea6 100644 --- a/build.gradle +++ b/build.gradle @@ -43,7 +43,7 @@ dependencies { } group = 'org.labkey.build' -project.version = "9.3.0-SNAPSHOT" +project.version = "9.3.0-configCacheClasses-SNAPSHOT" gradlePlugin { plugins { diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index eb84db68..69dd0d04 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.0-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.0-bin.zip networkTimeout=10000 retries=0 retryBackOffMs=500 diff --git a/gradlew b/gradlew index b9bb139f..249efbb0 100755 --- a/gradlew +++ b/gradlew @@ -20,7 +20,7 @@ ############################################################################## # -# Gradle start up script for POSIX generated by Gradle. +# gradlew start up script for POSIX generated by Gradle. # # Important for running: # @@ -29,7 +29,7 @@ # bash, then to run this script, type that shell name before the whole # command line, like: # -# ksh Gradle +# ksh gradlew # # Busybox and similar reduced shells will NOT work, because this script # requires all of these POSIX shell features: diff --git a/gradlew.bat b/gradlew.bat index 24c62d56..a51ec4f5 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -19,7 +19,7 @@ @if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem -@rem Gradle startup script for Windows +@rem gradlew startup script for Windows @rem @rem ########################################################################## @@ -72,7 +72,7 @@ echo location of your Java installation. 1>&2 -@rem Execute Gradle +@rem Execute gradlew @rem endlocal doesn't take effect until after the line is parsed and variables are expanded @rem which allows us to clear the local environment before executing the java command endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel diff --git a/src/main/groovy/org/labkey/gradle/plugin/Api.groovy b/src/main/groovy/org/labkey/gradle/plugin/Api.groovy index 762c4d98..7618dd34 100644 --- a/src/main/groovy/org/labkey/gradle/plugin/Api.groovy +++ b/src/main/groovy/org/labkey/gradle/plugin/Api.groovy @@ -19,6 +19,7 @@ import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.api.attributes.Usage import org.gradle.api.file.DuplicatesStrategy +import org.gradle.api.file.FileTree import org.gradle.api.tasks.Copy import org.gradle.api.tasks.bundling.Jar import org.labkey.gradle.plugin.extension.LabKeyExtension @@ -124,13 +125,14 @@ class Api implements Plugin } } - // It may seem proper to make this action a dependency on the project's clean task since the - // jar file is put there by the build task, but since the copy is more of a deployment - // task than a build task and removing it will affect the running server, we make this - // deletion a step for the 'undeployModule' task instead - static void deleteModulesApiJar(Project project) + /** + * @param project the project whose api jar files are to be found + * @return the api jar files copied to the {@link #MODULES_API_DIR} directory for this project. The tree is not + * resolved until it is queried, so it can be used as a property value for a task that deletes these files. + */ + static FileTree getModulesApiJars(Project project) { - project.delete project.fileTree(project.rootProject.layout.buildDirectory.file(MODULES_API_DIR)) {include "**/${project.name}_api*.jar"} + return project.fileTree(project.rootProject.layout.buildDirectory.file(MODULES_API_DIR)) {include "**/${project.name}_api*.jar"} } private void addArtifacts(Project project) diff --git a/src/main/groovy/org/labkey/gradle/plugin/ApplyLicenses.groovy b/src/main/groovy/org/labkey/gradle/plugin/ApplyLicenses.groovy index 4b801d65..4b947909 100644 --- a/src/main/groovy/org/labkey/gradle/plugin/ApplyLicenses.groovy +++ b/src/main/groovy/org/labkey/gradle/plugin/ApplyLicenses.groovy @@ -15,11 +15,10 @@ */ package org.labkey.gradle.plugin -import org.gradle.api.GradleException import org.gradle.api.Plugin import org.gradle.api.Project -import org.gradle.api.file.DuplicatesStrategy -import org.gradle.api.tasks.bundling.Jar +import org.labkey.gradle.task.PatchApiModule +import org.labkey.gradle.task.VerifyLicensePatch import org.labkey.gradle.util.BuildUtils import org.labkey.gradle.util.GroupNames @@ -68,8 +67,8 @@ class ApplyLicenses implements Plugin private static void addTasks(Project project) { if (!BuildUtils.isOpenSource(project)) { - var patchApiTask = project.tasks.register('patchApiModule', Jar) { - Jar jar -> + var patchApiTask = project.tasks.register('patchApiModule', PatchApiModule) { + PatchApiModule jar -> jar.group = GroupNames.DISTRIBUTION jar.description = "Patches the api module to replace ExtJS libraries with commercial versions" jar.archiveBaseName.set("api") @@ -77,27 +76,9 @@ class ApplyLicenses implements Plugin jar.archiveClassifier.set("extJsCommercial") jar.archiveExtension.set('module') jar.destinationDirectory.set(project.layout.buildDirectory.dir("patchApiModule")) - jar.outputs.cacheIf({ true }) - // first include the ext-3.4.1 and ext-4.2.1 directories from the extjs configuration artifacts - jar.into('web') { - from project.configurations.extJs3Commercial.collect { - project.zipTree(it) - } - } - jar.into('web') { - from project.configurations.extJs4Commercial.collect { - project.zipTree(it) - } - } - // include the original module file ... - jar.from project.configurations.licensePatch.collect { - project.zipTree(it).matching { - // DuplicatesStrategy.EXCLUDE doesn't seem to work in some environments - exclude('web/ext-*/**') - } - } - // ... but don't use the ext directories that come from that file - jar.setDuplicatesStrategy(DuplicatesStrategy.EXCLUDE) + jar.extJs3Archives.from(project.configurations.extJs3Commercial) + jar.extJs4Archives.from(project.configurations.extJs4Commercial) + jar.moduleArchives.from(project.configurations.licensePatch) jar.manifest.attributes( "Implementation-Version": project.version, "Implementation-Title": "Internal API classes", @@ -110,23 +91,12 @@ class ApplyLicenses implements Plugin } } - project.tasks.register('verifyLicensePatch') { - it.group = GroupNames.TEST - it.dependsOn(patchApiTask) - it.doLast { - [project.configurations.extJs3Commercial, project.configurations.extJs4Commercial].forEach { - def commercialLicense = project.zipTree(it.singleFile).matching { - include '*/license.txt' - }.singleFile - def patchedLicense = project.zipTree(patchApiTask.get().outputs.files.singleFile).matching { - include 'web/' + commercialLicense.parentFile.name + '/license.txt' - }.singleFile - if (commercialLicense.length() != patchedLicense.length()) { - throw new GradleException("License files didn't match for " + commercialLicense.parentFile.name) - } - } - } - it.notCompatibleWithConfigurationCache("Needs to inject ArtifactOperations for zipTree usage") + project.tasks.register('verifyLicensePatch', VerifyLicensePatch) { + VerifyLicensePatch verify -> + verify.group = GroupNames.TEST + verify.description = "Verifies that the patched api module contains the commercial ExtJS license files" + verify.commercialArchives.from(project.configurations.extJs3Commercial, project.configurations.extJs4Commercial) + verify.patchedArchive.set(patchApiTask.flatMap { PatchApiModule jar -> jar.archiveFile }) } } } diff --git a/src/main/groovy/org/labkey/gradle/plugin/ClientLibraries.groovy b/src/main/groovy/org/labkey/gradle/plugin/ClientLibraries.groovy index 085d9683..1bb531e0 100644 --- a/src/main/groovy/org/labkey/gradle/plugin/ClientLibraries.groovy +++ b/src/main/groovy/org/labkey/gradle/plugin/ClientLibraries.groovy @@ -50,8 +50,7 @@ class ClientLibraries task.description = 'create minified, compressed javascript file using .lib.xml sources' task.dependsOn(project.tasks.processResources) task.dependsOn(project.project(minProjectPath).tasks.named("npmInstall")) - task.xmlFiles = getLibXmlFiles(project) - task.notCompatibleWithConfigurationCache("Class ClientLibsCompress needs more input and output properties declared") + task.xmlFiles.from(getLibXmlFiles(project)) } project.evaluationDependsOn(minProjectPath) diff --git a/src/main/groovy/org/labkey/gradle/plugin/FileModule.groovy b/src/main/groovy/org/labkey/gradle/plugin/FileModule.groovy index 3e791217..53d38786 100644 --- a/src/main/groovy/org/labkey/gradle/plugin/FileModule.groovy +++ b/src/main/groovy/org/labkey/gradle/plugin/FileModule.groovy @@ -25,16 +25,15 @@ import org.gradle.api.artifacts.Dependency import org.gradle.api.artifacts.ProjectDependency import org.gradle.api.attributes.Attribute import org.gradle.api.attributes.Usage -import org.gradle.api.file.CopySpec -import org.gradle.api.file.DuplicatesStrategy import org.gradle.api.java.archives.Manifest import org.gradle.api.publish.maven.MavenPublication -import org.gradle.api.tasks.Delete import org.gradle.api.tasks.bundling.Jar import org.labkey.gradle.plugin.extension.LabKeyExtension import org.labkey.gradle.plugin.extension.ModuleExtension import org.labkey.gradle.plugin.extension.ServerDeployExtension +import org.labkey.gradle.task.DeployModule import org.labkey.gradle.task.ModuleXmlFile +import org.labkey.gradle.task.UndeployModule import org.labkey.gradle.util.BuildUtils import org.labkey.gradle.util.GroupNames import org.labkey.gradle.util.PomFileHelper @@ -182,55 +181,20 @@ class FileModule implements Plugin published(moduleTask) } - project.tasks.register('deployModule') - { Task task -> + project.tasks.register('deployModule', DeployModule) + { DeployModule task -> task.group = GroupNames.MODULE task.description = "copy a project's .module file to the local deploy directory" - task.inputs.files moduleTask - task.outputs.file "${ServerDeployExtension.getModulesDeployDirectory(project)}/${moduleTask.get().outputs.getFiles()[0].getName()}" - - task.doLast { - project.copy { CopySpec copy -> - copy.from moduleTask - copy.from project.configurations.modules - copy.into "${BuildUtils.getRootBuildDirPath(project)}/$ServerDeploy.STAGING_MODULES_DIR" - copy.setDuplicatesStrategy(DuplicatesStrategy.INCLUDE) - } - project.copy { CopySpec copy -> - copy.from moduleTask - copy.from project.configurations.modules - copy.into ServerDeployExtension.getModulesDeployDirectory(project) - copy.setDuplicatesStrategy(DuplicatesStrategy.INCLUDE) - } - BuildUtils.updateRestartTriggerFile(project) - } - task.notCompatibleWithConfigurationCache("Needs its own class to do the two copies (one to staging and one to deploy or possibly two Copy tasks chained together.") + task.moduleFiles.from(moduleTask, project.configurations.modules) + task.moduleFileName.set(moduleTask.flatMap { Jar jar -> jar.archiveFileName }) } - project.tasks.register('undeployModule', Delete) { - Delete task -> + project.tasks.register('undeployModule', UndeployModule) { + UndeployModule task -> task.group = GroupNames.MODULE task.description = "remove a project's .module file and the unjarred file from the deploy directory" - task.configure( - { Delete delete -> - getModuleFilesAndDirectories(project).forEach({ - File file -> - if (file.isDirectory()) - delete.inputs.dir file - else - delete.inputs.file file - }) - }) - task.doFirst { - undeployModule(project) - Api.deleteModulesApiJar(project) - } - task.doLast { - BuildUtils.updateRestartTriggerFile(project) - } - task.notCompatibleWithConfigurationCache("Does multiple deletes using project.delete. Should have its own class.") } project.tasks.register("reallyClean") { @@ -270,11 +234,28 @@ class FileModule implements Plugin */ static List getModuleFilesAndDirectories(Project project, Boolean includeDeployed = true, Boolean includeStaging=true) { - String moduleFilePrefix = "${project.name}-" + return getModuleFilesAndDirectories( + project.name, + includeDeployed ? new File(ServerDeployExtension.getModulesDeployDirectory(project)) : null, + includeStaging ? BuildUtils.getRootBuildDirFile(project, ServerDeploy.STAGING_MODULES_DIR) : null + ) + } + + /** + * The same as {@link #getModuleFilesAndDirectories(Project, Boolean, Boolean)} but without any reference to a + * project, so it can be used from a task action. + * @param moduleName the name of the module whose files are to be found + * @param deployDir the deploy directory to look in, or null to skip the deploy directory + * @param stagingDir the staging directory to look in, or null to skip the staging directory + * @return list of files and directories for this module with the deploy .module files first, followed by the deploy + * directories followed by the staging .module files. + */ + static List getModuleFilesAndDirectories(String moduleName, File deployDir, File stagingDir) + { + String moduleFilePrefix = "${moduleName}-" List files = new ArrayList<>() - if (includeDeployed) + if (deployDir != null) { - File deployDir = new File(ServerDeployExtension.getModulesDeployDirectory(project)) if (deployDir.isDirectory()) { // first add the files because we want to delete these first. If the directory goes away and the .module file is there @@ -293,17 +274,15 @@ class FileModule implements Plugin @Override boolean accept(final File file) { - return file.isDirectory() && (file.getName().startsWith("${project.name}-") || file.getName().equalsIgnoreCase(project.name)) + return file.isDirectory() && (file.getName().startsWith(moduleFilePrefix) || file.getName().equalsIgnoreCase(moduleName)) } }) ) } } // staging has only the .modules files - if (includeStaging) + if (stagingDir != null) { - - File stagingDir = BuildUtils.getRootBuildDirFile(project, ServerDeploy.STAGING_MODULES_DIR) if (stagingDir.isDirectory()) { files.addAll(stagingDir.listFiles(new FilenameFilter() { diff --git a/src/main/groovy/org/labkey/gradle/plugin/MultiGit.groovy b/src/main/groovy/org/labkey/gradle/plugin/MultiGit.groovy index 1becec95..b3857887 100644 --- a/src/main/groovy/org/labkey/gradle/plugin/MultiGit.groovy +++ b/src/main/groovy/org/labkey/gradle/plugin/MultiGit.groovy @@ -45,9 +45,6 @@ import java.util.stream.Collectors import static org.labkey.gradle.plugin.MultiGit.RepositoryQuery.getAuthorizationToken /** - * This is an incubating feature set. Interfaces and functionality are likely to change, perhaps drastically, - * before it is released. - * * This plugin can be used to get data about a gradle project that is comprised of multiple git repositories. * It uses the GitHub GraphQL API (https://developer.github.com/v4/) to query for a set of repositories. Using * the properties gitTopics, requireAllTopics, and includeArchived, a user is able to filter to a certain set @@ -981,6 +978,7 @@ class MultiGit implements Plugin } println(builder.toString()) }) + task.notCompatibleWithConfigurationCache("Needs properties converted to inputs") } project.tasks.register("gitBranches") { @@ -1018,6 +1016,7 @@ class MultiGit implements Plugin println(builder.toString()) }) + task.notCompatibleWithConfigurationCache("Needs properties converted to inputs") } project.tasks.register("gitCheckout") { @@ -1062,6 +1061,7 @@ class MultiGit implements Plugin } }) }) + task.notCompatibleWithConfigurationCache("Needs properties converted to inputs") } project.tasks.register("gitStatus") { @@ -1125,6 +1125,7 @@ class MultiGit implements Plugin } }) }) + task.notCompatibleWithConfigurationCache("Needs properties converted to inputs") } project.tasks.register("gitPull") { @@ -1157,6 +1158,7 @@ class MultiGit implements Plugin } }) }) + task.notCompatibleWithConfigurationCache("Needs properties converted to inputs") } project.tasks.register("gitFetch") { @@ -1182,6 +1184,7 @@ class MultiGit implements Plugin } }) }) + task.notCompatibleWithConfigurationCache("Needs properties converted to inputs") } @@ -1208,6 +1211,7 @@ class MultiGit implements Plugin } }) }) + task.notCompatibleWithConfigurationCache("Needs properties converted to inputs") } project.tasks.register("gitEnlist") { @@ -1236,6 +1240,7 @@ class MultiGit implements Plugin enlist(repositories, repository, enlisted, project.hasProperty('branch') ? (String) project.property('branch') : null) }) }) + task.notCompatibleWithConfigurationCache("Needs properties converted to inputs") } project.tasks.register("listPullRequests") { @@ -1262,12 +1267,8 @@ class MultiGit implements Plugin } } }) + task.notCompatibleWithConfigurationCache("Needs properties converted to inputs") } - - // - // TODO Add tasks for releasing - // - branch - // - release } private String getEchoHeader(Map repositories, Project project) diff --git a/src/main/groovy/org/labkey/gradle/plugin/ServerDeploy.groovy b/src/main/groovy/org/labkey/gradle/plugin/ServerDeploy.groovy index adc54798..9711a481 100644 --- a/src/main/groovy/org/labkey/gradle/plugin/ServerDeploy.groovy +++ b/src/main/groovy/org/labkey/gradle/plugin/ServerDeploy.groovy @@ -28,15 +28,12 @@ import org.labkey.gradle.task.DeployApp import org.labkey.gradle.task.DeployDistribution import org.labkey.gradle.task.StageDistribution import org.labkey.gradle.task.StageModules +import org.labkey.gradle.task.SymlinkNode import org.labkey.gradle.task.UndeployModules import org.labkey.gradle.util.BuildUtils import org.labkey.gradle.util.GroupNames import org.labkey.gradle.util.TaskUtils -import java.nio.file.Files -import java.nio.file.Path -import java.nio.file.Paths - /** * First stages then deploys the application locally to the tomcat directory */ @@ -180,18 +177,22 @@ class ServerDeploy implements Plugin // At a later date, we can possibly make this task execute the mklink command (and its counterpart to remove the link). // This would require that the gradle tasks be run as an administrator, and that is possibly not ideal. if (!SystemUtils.IS_OS_WINDOWS && project.hasProperty('nodeVersion')) { - project.tasks.register("symlinkNode") { - Task task -> + project.tasks.register("symlinkNode", SymlinkNode) { + SymlinkNode task -> task.group = GroupNames.DEPLOY task.description = "Make a symbolic link to the npm directory for use in PATH environment variable" - task.doFirst({ - if (project.hasProperty('npmVersion') && project.hasProperty('npmWorkDirectory')) - linkBinaries(project, "npm", project.npmVersion, project.npmWorkDirectory) - }) + task.nodeVersion.set((String) project.property('nodeVersion')) + Project nodeBinProject = project.findProject(BuildUtils.getNodeBinProjectPath(project.gradle)) + if (nodeBinProject != null && project.hasProperty('npmVersion') && project.hasProperty('npmWorkDirectory')) + { + task.npmVersion.set((String) project.property('npmVersion')) + task.linkContainerDir.set(new File("${project.rootDir}/${project.npmWorkDirectory}")) + task.npmTargetDir.set(nodeBinProject.file(project.npmWorkDirectory)) + if (project.hasProperty('nodeWorkDirectory')) + task.nodeTargetDir.set(nodeBinProject.file(project.nodeWorkDirectory)) + } task.dependsOn(project.tasks.npmSetup) - task.notCompatibleWithConfigurationCache("Needs its own class to declare proper input and output properties") } - project.tasks.symlinkNode.notCompatibleWithConfigurationCache("References project properties. Need to add task class with input properties") project.tasks.named('deployApp').configure {dependsOn(project.tasks.symlinkNode)} } @@ -236,7 +237,6 @@ class ServerDeploy implements Plugin UndeployModules task -> task.group = GroupNames.DEPLOY task.description = "Removes all module files and directories from the deploy and staging directories" - task.notCompatibleWithConfigurationCache("Walks the project tree") } project.tasks.register( @@ -281,44 +281,4 @@ class ServerDeploy implements Plugin } } - private static linkBinaries(Project project, String packageMgr, String version, workDirectory) { - - Project pmLinkProject = project.findProject(BuildUtils.getNodeBinProjectPath(project.gradle)) - if (pmLinkProject == null) - return - - File linkContainer = new File("${project.rootDir}/${project.npmWorkDirectory}") - linkContainer.mkdirs() - - Path pmLinkPath = Paths.get("${linkContainer.getPath()}/${packageMgr}") - String pmDirName = "${packageMgr}-v${version}" - Path pmTargetPath = Paths.get(pmLinkProject.file( "${workDirectory}/${pmDirName}").getPath()) - - if (!Files.isSymbolicLink(pmLinkPath) || !Files.readSymbolicLink(pmLinkPath).getFileName().toString().equals(pmDirName)) - { - // if the symbolic link exists, we want to replace it - if (Files.isSymbolicLink(pmLinkPath)) - Files.delete(pmLinkPath) - - Files.createSymbolicLink(pmLinkPath, pmTargetPath) - } - - String nodeFilePrefix = "node-v${project.nodeVersion}-" - Path nodeLinkPath = Paths.get("${linkContainer.getPath()}/node") - if (!Files.isSymbolicLink(nodeLinkPath) || !Files.readSymbolicLink(nodeLinkPath).getFileName().toString().startsWith(nodeFilePrefix)) - { - File nodeDir = pmLinkProject.file(project.nodeWorkDirectory) - File[] nodeFiles = nodeDir.listFiles({ File file -> file.name.startsWith(nodeFilePrefix) } as FileFilter) - if (nodeFiles != null && nodeFiles.length > 0) - { - // if the symbolic link exists, we want to replace it - if (Files.isSymbolicLink(nodeLinkPath)) - Files.delete(nodeLinkPath) - - Files.createSymbolicLink(nodeLinkPath, nodeFiles[0].toPath()) - } - else - project.logger.warn("No file found with prefix ${nodeDir.path}/${nodeFilePrefix}. Symbolic link in ${linkContainer.getPath()}/node not created.") - } - } } diff --git a/src/main/groovy/org/labkey/gradle/plugin/TeamCity.groovy b/src/main/groovy/org/labkey/gradle/plugin/TeamCity.groovy index 47a28852..d65869a9 100644 --- a/src/main/groovy/org/labkey/gradle/plugin/TeamCity.groovy +++ b/src/main/groovy/org/labkey/gradle/plugin/TeamCity.groovy @@ -31,7 +31,7 @@ import org.labkey.gradle.plugin.extension.TeamCityExtension import org.labkey.gradle.task.PickDb import org.labkey.gradle.task.RunTestSuite import org.labkey.gradle.task.TeamCityDbSetup -import org.labkey.gradle.task.UndeployModules +import org.labkey.gradle.task.WriteStartupProperties import org.labkey.gradle.util.BuildUtils import org.labkey.gradle.util.DatabaseProperties import org.labkey.gradle.util.GroupNames @@ -129,14 +129,10 @@ class TeamCity extends Tomcat } } - project.tasks.register("createStartupPropertyFile") { - doLast { - String properties = extension.getTeamCityProperty('labkey.startup.properties') - - if (!properties.isBlank()) { - extension.writeStartupProperties('99_teamcity_startup.properties', properties) - } - } + project.tasks.register("createStartupPropertyFile", WriteStartupProperties) { + WriteStartupProperties task -> + task.propertiesFile.set(TeamCityExtension.startupPropertiesFile(project, '99_teamcity_startup.properties')) + task.propertiesContent.set(extension.getTeamCityProperty('labkey.startup.properties')) } project.tasks.named("startLabKey").configure { @@ -196,35 +192,6 @@ class TeamCity extends Tomcat } TaskProvider setUpDbTask = project.tasks.named(setUpTaskName) - - // TODO we need a counterpart of this for embedded tomcat server. Probably we'll want to - // make the deployment extract the module files so we can walk through them to remove the - // ones that are not supported. But, undeployModule currently knows nothing about the build/deploy/embedded - // directory, so that needs to be updated as well. - String undeployTaskName = "undeployModulesNotFor${properties.shortType.capitalize()}" - Provider undeployTask - try { - undeployTask = project.tasks.named(undeployTaskName) - } catch (UnknownTaskException ignore) { - project.tasks.register(undeployTaskName, UndeployModules) { - UndeployModules task -> - task.group = GroupNames.DEPLOY - task.description = "Undeploy modules that are either not supposed to be built or are not supported by database ${properties.dbTypeAndVersion}" - task.dbType = properties.shortType - task.mustRunAfter(BuildUtils.getServerProject(project).tasks.pickMSSQL) - task.mustRunAfter(BuildUtils.getServerProject(project).tasks.pickPg) - task.notCompatibleWithConfigurationCache("Walks the project tree") - } - } - undeployTask = project.tasks.named(undeployTaskName) - project.tasks.named("startLabKey").configure { - it.mustRunAfter(undeployTask) - } - - project.tasks.named("startTomcat").configure { - it.mustRunAfter(undeployTask) - } - project.project(BuildUtils.getTestProjectPath(project.gradle)).tasks.startLabKey.mustRunAfter(setUpDbTask) project.project(BuildUtils.getTestProjectPath(project.gradle)).tasks.startTomcat.mustRunAfter(setUpDbTask) String ciTestTaskName = "ciTests" + properties.dbTypeAndVersion.capitalize() @@ -248,23 +215,20 @@ class TeamCity extends Tomcat { String inheritedDistPath = extension.getTeamCityProperty('labkey.startup.includeDistModules') project.evaluationDependsOn(inheritedDistPath) - def includeDistModulesTask = project.tasks.register("includeDistModules", Task) { - Task task -> + def includeDistModulesTask = project.tasks.register("includeDistModules", WriteStartupProperties) { + WriteStartupProperties task -> task.group = GroupNames.TEST_SERVER task.description = "Generate server properties file to run with modules from a specified distribution" - task.doLast { - task.logger.info("inheriting from distribution ${inheritedDistPath}") - Set includeModules = new HashSet<>() - project.project(inheritedDistPath).configurations.distribution.dependencies.each { - includeModules.add(it.getName()) - } + project.logger.info("inheriting from distribution ${inheritedDistPath}") + Set includeModules = new HashSet<>() + project.project(inheritedDistPath).configurations.distribution.dependencies.each { + includeModules.add(it.getName()) + } - includeModules.addAll(extension.getTeamCityProperty('labkey.startup.includeDistModules.additional').split(',')) + includeModules.addAll(extension.getTeamCityProperty('labkey.startup.includeDistModules.additional').split(',')) - extension.writeStartupProperties('00_modulesInclude.properties', - 'ModuleLoader.include;startup=' + String.join(',', includeModules)) - } - task.notCompatibleWithConfigurationCache("Needs the distribution configuration specified as an input ConfigurableFileCollection") + task.propertiesFile.set(TeamCityExtension.startupPropertiesFile(project, '00_modulesInclude.properties')) + task.propertiesContent.set('ModuleLoader.include;startup=' + String.join(',', includeModules)) } project.tasks.named("startLabKey").configure { diff --git a/src/main/groovy/org/labkey/gradle/plugin/TestRunner.groovy b/src/main/groovy/org/labkey/gradle/plugin/TestRunner.groovy index ec32170b..2c65ebd6 100644 --- a/src/main/groovy/org/labkey/gradle/plugin/TestRunner.groovy +++ b/src/main/groovy/org/labkey/gradle/plugin/TestRunner.groovy @@ -17,6 +17,7 @@ package org.labkey.gradle.plugin import org.gradle.api.Project import org.gradle.api.Task +import org.gradle.api.file.FileCollection import org.gradle.api.tasks.JavaExec import org.labkey.gradle.plugin.extension.TeamCityExtension import org.labkey.gradle.task.RunTestSuite @@ -40,6 +41,20 @@ class TestRunner extends UiTest } + // Declared here, rather than in the project's build file, so it is available when the tasks are added + @Override + protected void addConfigurations(Project project) + { + super.addConfigurations(project) + project.configurations { + aspectj { + canBeConsumed = false + canBeResolved = true + } + } + project.configurations.aspectj.setDescription("AspectJ tools used to weave the UI test classes") + } + @Override protected void addSourceSets(Project project) { @@ -157,28 +172,38 @@ class TestRunner extends UiTest } } - private void addAspectJ(Project project) + // This method is static, and the values used by the task action are captured here, so the action does not reference + // the project or this plugin (which holds the uiTest extension, which references the project) + private static void addAspectJ(Project project) { + FileCollection aspectJClasspath = project.configurations.aspectj + FileCollection uiTestClasspath = project.configurations.uiTestRuntimeClasspath + // A live view of the source directories, so directories added by the project's build file after this plugin is + // applied are still included + FileCollection srcDirs = project.sourceSets.uiTest.java.sourceDirectories + File destinationDir = BuildUtils.getBuildDirFile(project,"classes/java/uiTest/") + String sourceCompatibility = (String) project.rootProject.property('sourceCompatibility') + String targetCompatibility = (String) project.rootProject.property('targetCompatibility') + project.tasks.named('compileUiTestJava').configure {it -> - it.doLast { - ant.taskdef( + it.doLast { Task task -> + task.ant.taskdef( resource: "org/aspectj/tools/ant/taskdefs/aspectjTaskdefs.properties", - classpath: project.configurations.aspectj.asPath + classpath: aspectJClasspath.asPath ) - ant.iajc( - destdir: BuildUtils.getBuildDirFile(project,"classes/java/uiTest/").getPath(), - source: project.sourceCompatibility, - target: project.targetCompatibility, + task.ant.iajc( + destdir: destinationDir.getPath(), + source: sourceCompatibility, + target: targetCompatibility, encoding: "UTF-8", - classpath: project.configurations.uiTestRuntimeClasspath.asPath, + classpath: uiTestClasspath.asPath, { - project.sourceSets.uiTest.java.srcDirs.each { + srcDirs.each { src(path: it) } } ) } - it.notCompatibleWithConfigurationCache("Needs configurations adn sourceSets specified as ConfigurableFileCollection.") } } } diff --git a/src/main/groovy/org/labkey/gradle/plugin/Tomcat.groovy b/src/main/groovy/org/labkey/gradle/plugin/Tomcat.groovy index d19324de..ae1cab74 100644 --- a/src/main/groovy/org/labkey/gradle/plugin/Tomcat.groovy +++ b/src/main/groovy/org/labkey/gradle/plugin/Tomcat.groovy @@ -58,7 +58,6 @@ class Tomcat implements Plugin StartLabKey task -> task.group = GroupNames.WEB_APPLICATION task.description = "Start the LabKey web application" - task.notCompatibleWithConfigurationCache("Needs some properties converted to inputs and outputs") } project.tasks.register("stopLabKey", StopLabKey) { @@ -72,7 +71,6 @@ class Tomcat implements Plugin StartLabKey task -> task.group = GroupNames.WEB_APPLICATION task.description = "Start the LabKey web application (deprecated: use startLabKey)" - task.notCompatibleWithConfigurationCache("Needs some properties converted to inputs and outputs") } project.tasks.register("stopTomcat", StopLabKey) { diff --git a/src/main/groovy/org/labkey/gradle/plugin/UiTest.groovy b/src/main/groovy/org/labkey/gradle/plugin/UiTest.groovy index 7f3ab3be..81c17f34 100644 --- a/src/main/groovy/org/labkey/gradle/plugin/UiTest.groovy +++ b/src/main/groovy/org/labkey/gradle/plugin/UiTest.groovy @@ -93,7 +93,6 @@ class UiTest implements Plugin task.mustRunAfter(serverProject.tasks.pickPg) task.mustRunAfter(serverProject.tasks.pickMSSQL) } - task.notCompatibleWithConfigurationCache("Needs some properties set for various project references.") } } diff --git a/src/main/groovy/org/labkey/gradle/plugin/extension/TeamCityExtension.groovy b/src/main/groovy/org/labkey/gradle/plugin/extension/TeamCityExtension.groovy index 963a7251..c5fb0815 100644 --- a/src/main/groovy/org/labkey/gradle/plugin/extension/TeamCityExtension.groovy +++ b/src/main/groovy/org/labkey/gradle/plugin/extension/TeamCityExtension.groovy @@ -17,6 +17,7 @@ package org.labkey.gradle.plugin.extension import org.apache.commons.io.FileUtils import org.gradle.api.Project +import org.gradle.api.file.RegularFile import org.labkey.gradle.util.DatabaseProperties import java.nio.charset.StandardCharsets @@ -192,6 +193,15 @@ class TeamCityExtension return startupDir } + /** + * @param project the project whose deploy directory the startup properties are written to + * @param fileName the name of the startup properties file + * @return the startup properties file, without creating its directory, so it can be used as a task's output file + */ + static RegularFile startupPropertiesFile(Project project, String fileName) { + return ServerDeployExtension.getEmbeddedServerDeployDirectory(project).dir('startup').file(fileName) + } + void writeStartupProperties(String fileName, String properties) { File propFile = new File(startupPropertiesDir(), fileName) diff --git a/src/main/groovy/org/labkey/gradle/plugin/extension/UiTestExtension.groovy b/src/main/groovy/org/labkey/gradle/plugin/extension/UiTestExtension.groovy index 9243b315..b6defd8c 100644 --- a/src/main/groovy/org/labkey/gradle/plugin/extension/UiTestExtension.groovy +++ b/src/main/groovy/org/labkey/gradle/plugin/extension/UiTestExtension.groovy @@ -89,7 +89,7 @@ class UiTestExtension if (TeamCityExtension.isOnTeamCity(project)) { // Load properties from template when running on TeamCity. - // These properties control which TeamCity properties are loaded by `RunTestSuite.setTeamCityProperties` + // These properties control which TeamCity properties are loaded by `RunTestSuite.configureTeamCityProperties` def propertiesTemplate = project.project(BuildUtils.getTestProjectPath(project.gradle)).file(propertiesTemplateName) if (propertiesTemplate.exists()) { diff --git a/src/main/groovy/org/labkey/gradle/task/ClientLibsCompress.groovy b/src/main/groovy/org/labkey/gradle/task/ClientLibsCompress.groovy index 0daa9da4..036e5aa7 100644 --- a/src/main/groovy/org/labkey/gradle/task/ClientLibsCompress.groovy +++ b/src/main/groovy/org/labkey/gradle/task/ClientLibsCompress.groovy @@ -22,9 +22,11 @@ import org.apache.tools.ant.util.FileUtils import org.gradle.api.DefaultTask import org.gradle.api.GradleException import org.gradle.api.Project -import org.gradle.api.file.FileCollection -import org.gradle.api.file.FileTree +import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.logging.Logger import org.gradle.api.provider.Property +import org.gradle.api.tasks.CacheableTask import org.gradle.api.tasks.Input import org.gradle.api.tasks.InputFiles import org.gradle.api.tasks.Internal @@ -33,6 +35,9 @@ import org.gradle.api.tasks.OutputFiles import org.gradle.api.tasks.PathSensitive import org.gradle.api.tasks.PathSensitivity import org.gradle.api.tasks.TaskAction +import org.gradle.process.ExecOperations +import org.gradle.process.ExecResult +import org.gradle.process.ExecSpec import org.gradle.work.DisableCachingByDefault import org.labkey.gradle.plugin.NpmRun import org.labkey.gradle.plugin.extension.LabKeyExtension @@ -41,26 +46,28 @@ import org.xml.sax.Attributes import org.xml.sax.SAXException import org.xml.sax.helpers.DefaultHandler +import javax.inject.Inject import javax.xml.parsers.SAXParser import javax.xml.parsers.SAXParserFactory import java.nio.charset.StandardCharsets import java.util.stream.Collectors +import java.util.zip.GZIPOutputStream /** * Class for compressing javascript and css files */ -// TODO When caching is enabled, the moduleEditor[prod] suite fails to find the Ext libraries. -// No luck yet finding what inputs or outputs are not well configured. Perhaps when converted to -// use configuration cache something will become more clear. -@DisableCachingByDefault(because="Needs troubleshooting") -class ClientLibsCompress extends DefaultTask + +@CacheableTask +abstract class ClientLibsCompress extends DefaultTask { public static final String LIB_XML_EXTENSION = ".lib.xml" + @Inject abstract ExecOperations getExec() + // This returns the libXml files from the project directory (the actual input files) @InputFiles @PathSensitive(PathSensitivity.RELATIVE) - FileTree xmlFiles + abstract ConfigurableFileCollection getXmlFiles() private List inputFiles = null private List outputFiles = null @@ -72,21 +79,44 @@ class ClientLibsCompress extends DefaultTask @Input final abstract Property nodeVersion = project.objects.property(String).convention(project.hasProperty("nodeVersion") ? project.nodeVersion : "") + // The directory the minified files are written to, which is also the directory used to derive the output file names @Internal - String getWorkingDirPath() { - return new File((String) project.labkey.explodedModuleWebDir).getAbsolutePath() - } + final abstract Property workingDirPath = project.objects.property(String).convention(new File((String) project.labkey.explodedModuleWebDir).getAbsolutePath()) + + // The directory in the minification project where the package.json files and concatenated sources are written + @Internal + final abstract DirectoryProperty minificationDir = project.objects.directoryProperty().fileValue(getMinificationDir(project)) + + // The directory where the node distribution is unpacked by the minification project's npmInstall task + @Internal + final abstract DirectoryProperty nodeJsDir = project.objects.directoryProperty().fileValue(findNodeJsDir(project)) + + // Used only for error reporting when the node executable cannot be found + @Internal + final abstract Property minificationProjectPath = project.objects.property(String).convention(BuildUtils.getMinificationProjectPath(project.gradle)) + // Missing files are simply not snapshotted, so there is no need to filter for existence here @InputFiles @PathSensitive(PathSensitivity.RELATIVE) - FileCollection getNpmPackageFiles() { - if (BuildUtils.haveMinificationProject(project.gradle)) { - Project minProject = project.project(BuildUtils.getMinificationProjectPath(project.gradle)) - return project.files( - "${minProject.projectDir}/package.json", - "${minProject.projectDir}/package-lock.json" - ).filter { it.exists() } - } + final abstract ConfigurableFileCollection npmPackageFiles = project.objects.fileCollection().from(findNpmPackageFiles(project)) + + private static File findNodeJsDir(Project project) + { + if (!BuildUtils.haveMinificationProject(project.gradle)) + return null + Project minProject = project.project(BuildUtils.getMinificationProjectPath(project.gradle)) + return new File("${minProject.projectDir}/.gradle/nodejs") + } + + private static List findNpmPackageFiles(Project project) + { + if (!BuildUtils.haveMinificationProject(project.gradle)) + return Collections.emptyList() + Project minProject = project.project(BuildUtils.getMinificationProjectPath(project.gradle)) + return List.of( + new File("${minProject.projectDir}/package.json"), + new File("${minProject.projectDir}/package-lock.json") + ) } /** @@ -155,7 +185,7 @@ class ClientLibsCompress extends DefaultTask File getMinificationWorkingDir(File libXmlFile) { - return new File(getMinificationDir(project), "${libXmlFile.name.substring(0, libXmlFile.name.length() - LIB_XML_EXTENSION.length())}") + return new File(minificationDir.get().asFile, "${libXmlFile.name.substring(0, libXmlFile.name.length() - LIB_XML_EXTENSION.length())}") } @OutputFiles @@ -169,8 +199,8 @@ class ClientLibsCompress extends DefaultTask if (entry.value.doCompile) { // The output file will be in the working directory not in the source directory used when parsing the file. String fileName = entry.key.getAbsolutePath() - fileName = fileName.replace(entry.value.sourceDir.getAbsolutePath(), getWorkingDirPath()) - File workingFile = project.file(fileName) + fileName = fileName.replace(entry.value.sourceDir.getAbsolutePath(), workingDirPath.get()) + File workingFile = new File(fileName) if (entry.value.getCssFiles().size() > 0) { outputFiles.add(getOutputFile(workingFile, "min", "css")) if (!isDevMode.get()) @@ -205,9 +235,8 @@ class ClientLibsCompress extends DefaultTask @TaskAction void compressAllFiles() { - FileTree libXmlFiles = xmlFiles Map importerMap = getImporterMap() - libXmlFiles.files.each() { + xmlFiles.files.each() { File file -> compressSingleFile(file, importerMap.get(file)) } } @@ -238,7 +267,7 @@ class ClientLibsCompress extends DefaultTask SAXParser parser = factory.newSAXParser() // we pass in the source directory here because this directory is used for constructing // the destination files - XmlImporter importer = new XmlImporter(xmlFile, sourceDir) + XmlImporter importer = new XmlImporter(xmlFile, sourceDir, logger) parser.parse(xmlFile, importer) return importer } @@ -251,10 +280,9 @@ class ClientLibsCompress extends DefaultTask @Internal String getNodeExecutableDir() { - Project minProject = project.project(BuildUtils.getMinificationProjectPath(project.gradle)) String nodeFilePrefix = "node-v${nodeVersion.get()}-" - File nodeDir = new File("${minProject.projectDir}/.gradle/nodejs") - File[] nodeFiles = nodeDir.listFiles({ File file -> file.name.startsWith(nodeFilePrefix) } as FileFilter) + File nodeDir = nodeJsDir.getAsFile().getOrNull() + File[] nodeFiles = nodeDir == null ? null : nodeDir.listFiles({ File file -> file.name.startsWith(nodeFilePrefix) } as FileFilter) if (nodeFiles != null && nodeFiles.length > 0) return "${nodeFiles[0].getAbsolutePath()}${SystemUtils.IS_OS_WINDOWS ? '' : '/bin'}" else @@ -264,64 +292,47 @@ class ClientLibsCompress extends DefaultTask void minifyViaNpm(File xmlFile, XmlImporter importer) { if (importer.hasFilesToCompress()) { - String propPrefix = "minifiy${xmlFile.name.substring(0, xmlFile.name.length()-LIB_XML_EXTENSION.length())}" String executableDir = getNodeExecutableDir() + if (executableDir == null) + throw new GradleException("Could not find expected files in ${minificationProjectPath.get()} project") Pair minFiles = createPackageJson(xmlFile, importer) if (importer.hasJavascriptFiles()) { - if (executableDir == null) - throw new GradleException("Could not find expected files in ${BuildUtils.getMinificationProjectPath(project.gradle)} project") - project.logger.info("Compressing Javascript files for ${xmlFile} with ${executableDir} in ${getMinificationWorkingDir(xmlFile)}") - project.ant.exec( - outputproperty:"${propPrefix}JsText", - errorproperty: "${propPrefix}JsError", - resultproperty: "${propPrefix}JsExitValue", - executable: "${executableDir}/${NpmRun.getNpmCommand()}", - dir: getMinificationWorkingDir(xmlFile) - ) - { - arg(line: "run minify-js") - env( - key: "PATH", - value: "${executableDir}${File.pathSeparator}${System.getenv("PATH")}" - ) - } - project.logger.debug("${project.path} ${xmlFile} ant text ${project.ant.project.properties.get(propPrefix + 'JsText')}") - project.logger.debug("${project.path} ${xmlFile} ant error ${project.ant.project.properties.get(propPrefix + 'JsError')}") - project.logger.debug("${project.path} ${xmlFile} ant exitValue ${project.ant.project.properties.get(propPrefix + 'JsExitValue')}") - if (project.ant.project.properties.get(propPrefix + 'JsExitValue') != '0') - throw new GradleException("Error compressing Javascript files for ${xmlFile}. Exit code ${project.ant.project.properties.get(propPrefix + 'JsExitValue')}.\n Output: ${project.ant.project.properties.get(propPrefix + 'JsText')}.\n Error: ${project.ant.project.properties.get(propPrefix + 'JsError')} ") - - project.logger.debug("DONE Compressing Javascript files as ${minFiles.left}") + logger.info("Compressing Javascript files for ${xmlFile} with ${executableDir} in ${getMinificationWorkingDir(xmlFile)}") + runNpmScript(xmlFile, "minify-js", executableDir, "Javascript") + logger.debug("DONE Compressing Javascript files as ${minFiles.left}") compressFile(minFiles.left) } if (importer.hasCssFiles()) { - project.logger.info("Compressing css files for ${xmlFile}") - project.ant.exec( - outputproperty:"${propPrefix}CssText", - errorproperty: "${propPrefix}CssError", - resultproperty: "${propPrefix}CssExitValue", - executable: "${executableDir}/${NpmRun.getNpmCommand()}", - dir: getMinificationWorkingDir(xmlFile) - ) - { - arg(line: "run minify-css") - env( - key: "PATH", - value: "${executableDir}${File.pathSeparator}${System.getenv("PATH")}" - ) - } - project.logger.debug("${project.path} ${xmlFile} ant text ${project.ant.project.properties.get(propPrefix + 'CssText')}") - project.logger.debug("${project.path} ${xmlFile} ant error ${project.ant.project.properties.get(propPrefix + 'CssError')}") - project.logger.debug("${project.path} ${xmlFile} ant exitValue ${project.ant.project.properties.get(propPrefix + 'CssExitValue')}") - if (project.ant.project.properties.get(propPrefix + 'CssExitValue') != '0') - throw new GradleException("Error compressing css files for ${xmlFile}. Exit code ${project.ant.project.properties.get(propPrefix + 'CssExitValue')}.\n Output: ${project.ant.project.properties.get(propPrefix + 'CssText')}.\n Error: ${project.ant.project.properties.get(propPrefix + 'CssError')} ") - project.logger.debug("DONE Compressing css files as ${minFiles.right}") + logger.info("Compressing css files for ${xmlFile}") + runNpmScript(xmlFile, "minify-css", executableDir, "css") + logger.debug("DONE Compressing css files as ${minFiles.right}") compressFile(minFiles.right) } } } + private void runNpmScript(File xmlFile, String scriptName, String executableDir, String fileType) + { + ByteArrayOutputStream output = new ByteArrayOutputStream() + ByteArrayOutputStream error = new ByteArrayOutputStream() + ExecResult result = exec.exec({ ExecSpec spec -> + spec.executable = "${executableDir}/${NpmRun.getNpmCommand()}" + spec.args("run", scriptName) + spec.workingDir = getMinificationWorkingDir(xmlFile) + spec.environment("PATH", "${executableDir}${File.pathSeparator}${System.getenv("PATH")}") + spec.standardOutput = output + spec.errorOutput = error + // we report the failure ourselves, with the captured output, below + spec.ignoreExitValue = true + }) + logger.debug("${path} ${xmlFile} npm text ${output}") + logger.debug("${path} ${xmlFile} npm error ${error}") + logger.debug("${path} ${xmlFile} npm exitValue ${result.exitValue}") + if (result.exitValue != 0) + throw new GradleException("Error compressing ${fileType} files for ${xmlFile}. Exit code ${result.exitValue}.\n Output: ${output}.\n Error: ${error} ") + } + static String escapeBackslashPaths(String path) { return path.replaceAll("\\\\", "\\\\\\\\") @@ -333,10 +344,10 @@ class ClientLibsCompress extends DefaultTask File cssMinFile = null File sourceDir = getSourceDir(xmlFile) - File workingFile = new File(xmlFile.getAbsolutePath().replace(sourceDir.getAbsolutePath(), getWorkingDirPath())) + File workingFile = new File(xmlFile.getAbsolutePath().replace(sourceDir.getAbsolutePath(), workingDirPath.get())) File packageJson = new File(getMinificationWorkingDir(xmlFile), "package.json") - project.logger.info("Creating ${packageJson} for ${xmlFile.getAbsolutePath()}") + logger.info("Creating ${packageJson} for ${xmlFile.getAbsolutePath()}") String sanitizedName = xmlFile.name.substring(0, xmlFile.name.length()-LIB_XML_EXTENSION.length()) packageJson.createNewFile() StringBuffer buffer = new StringBuffer("") @@ -408,10 +419,11 @@ class ClientLibsCompress extends DefaultTask if (!isDevMode.get()) { this.logger.info("Compressing " + file) - project.ant.gzip( - src: file, - destfile: "${file}.gz" - ) + new FileInputStream(file).withStream { InputStream input -> + new GZIPOutputStream(new FileOutputStream("${file}.gz")).withStream { OutputStream output -> + IOUtils.copy(input, output) + } + } } } @@ -472,19 +484,21 @@ class ClientLibsCompress extends DefaultTask } } - private class XmlImporter extends DefaultHandler + private static class XmlImporter extends DefaultHandler { private boolean withinScriptsTag = false private File xmlFile private File sourceDir + private Logger logger private LinkedHashSet javascriptFiles = new LinkedHashSet<>() private LinkedHashSet cssFiles = new LinkedHashSet<>() private boolean doCompile = true - XmlImporter(File xml, File sourceDir) + XmlImporter(File xml, File sourceDir, Logger logger) { xmlFile = xml this.sourceDir = sourceDir + this.logger = logger } boolean hasFilesToCompress() diff --git a/src/main/groovy/org/labkey/gradle/task/DeployApp.groovy b/src/main/groovy/org/labkey/gradle/task/DeployApp.groovy index 04706209..efbe3fb7 100644 --- a/src/main/groovy/org/labkey/gradle/task/DeployApp.groovy +++ b/src/main/groovy/org/labkey/gradle/task/DeployApp.groovy @@ -58,7 +58,7 @@ abstract class DeployApp extends DeployAppBase final abstract DirectoryProperty deployBinDir = BuildUtils.getRootBuildDirectoryProperty(project, ServerDeploy.DEPLOY_BIN_DIR) @Input - final abstract Property useLocalBuild = project.objects.property(Boolean).convention(project.hasProperty("useLocalBuild") && "false" != project.property("useLocalBuild")) + final abstract Property useLocalBuild = project.objects.property(Boolean).convention(BuildUtils.useLocalBuild(project)) @OutputFile final abstract RegularFileProperty restartTriggerFile = project.objects.fileProperty().fileValue(BuildUtils.getRestartTriggerFile(project)) diff --git a/src/main/groovy/org/labkey/gradle/task/DeployModule.groovy b/src/main/groovy/org/labkey/gradle/task/DeployModule.groovy new file mode 100644 index 00000000..5dc3a30a --- /dev/null +++ b/src/main/groovy/org/labkey/gradle/task/DeployModule.groovy @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2026 LabKey Corporation + * + * 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 + * + * http://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 org.labkey.gradle.task + +import org.gradle.api.DefaultTask +import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.file.CopySpec +import org.gradle.api.file.Directory +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.DuplicatesStrategy +import org.gradle.api.file.FileSystemOperations +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputFiles +import org.gradle.api.tasks.Internal +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.work.DisableCachingByDefault +import org.labkey.gradle.plugin.ServerDeploy +import org.labkey.gradle.plugin.extension.ServerDeployExtension +import org.labkey.gradle.util.BuildUtils + +import javax.inject.Inject + +/** + * Copies a module's .module file, and the .module files it depends on, into the staging and deploy directories. + */ +@DisableCachingByDefault(because="Outputs are in the staging and deploy directories") +abstract class DeployModule extends DefaultTask +{ + @Inject abstract FileSystemOperations getFs() + + /** The .module file for this project, together with the .module files of the modules it depends on */ + @InputFiles + @PathSensitive(PathSensitivity.NAME_ONLY) + abstract ConfigurableFileCollection getModuleFiles() + + /** The name of this project's .module file, used to identify this task's output file in the deploy directory */ + @Internal + final abstract Property moduleFileName = project.objects.property(String) + + @Internal + final abstract DirectoryProperty stagingModulesDir = BuildUtils.getRootBuildDirectoryProperty(project, ServerDeploy.STAGING_MODULES_DIR) + + @Internal + final abstract DirectoryProperty deployModulesDir = project.objects.directoryProperty().fileValue(new File(ServerDeployExtension.getModulesDeployDirectory(project))) + + @OutputFile + final abstract RegularFileProperty deployedModuleFile = project.objects.fileProperty().value(deployModulesDir.file(moduleFileName)) + + @OutputFile + final abstract RegularFileProperty stagedModuleFile = project.objects.fileProperty().value(stagingModulesDir.file(moduleFileName)) + + @Input + final abstract Property useLocalBuild = project.objects.property(Boolean).convention(BuildUtils.useLocalBuild(project)) + + // Not declared as an output because it is shared with the other tasks that trigger a server restart + @Internal + final abstract RegularFileProperty restartTriggerFile = project.objects.fileProperty().fileValue(BuildUtils.getRestartTriggerFile(project)) + + @TaskAction + void action() + { + copyModuleFiles(stagingModulesDir.get()) + copyModuleFiles(deployModulesDir.get()) + BuildUtils.updateRestartTriggerFile(useLocalBuild.get(), restartTriggerFile.get().asFile) + } + + private void copyModuleFiles(Directory destination) + { + fs.copy({ CopySpec copy -> + copy.from moduleFiles + copy.into destination + copy.setDuplicatesStrategy(DuplicatesStrategy.INCLUDE) + }) + } +} diff --git a/src/main/groovy/org/labkey/gradle/task/ModuleDistribution.groovy b/src/main/groovy/org/labkey/gradle/task/ModuleDistribution.groovy index d44fd789..4af208e7 100644 --- a/src/main/groovy/org/labkey/gradle/task/ModuleDistribution.groovy +++ b/src/main/groovy/org/labkey/gradle/task/ModuleDistribution.groovy @@ -19,17 +19,25 @@ import org.apache.commons.lang3.StringUtils import org.gradle.api.DefaultTask import org.gradle.api.GradleException import org.gradle.api.Project +import org.gradle.api.file.ConfigurableFileCollection import org.gradle.api.file.CopySpec +import org.gradle.api.file.DirectoryProperty import org.gradle.api.file.DuplicatesStrategy import org.gradle.api.file.FileSystemOperations +import org.gradle.api.file.RegularFileProperty import org.gradle.api.provider.Property import org.gradle.api.tasks.CacheableTask import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputFiles +import org.gradle.api.tasks.Internal import org.gradle.api.tasks.Optional import org.gradle.api.tasks.OutputDirectory import org.gradle.api.tasks.OutputFile import org.gradle.api.tasks.OutputFiles +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.labkey.gradle.plugin.ApplyLicenses import org.labkey.gradle.plugin.extension.DistributionExtension import org.labkey.gradle.plugin.extension.LabKeyExtension @@ -64,21 +72,65 @@ abstract class ModuleDistribution extends DefaultTask @Input final abstract Property isDevDist = project.objects.property(Boolean).convention(project.hasProperty("devDistribution")) - private File distributionDir + @Internal + final abstract Property isOpenSource = project.objects.property(Boolean).convention(BuildUtils.isOpenSource(project)) - private final DistributionExtension distExtension - private Project licensingProject + // Used to derive the default name of the distribution when neither extraFileIdentifier nor subDirName is provided + @Input + final abstract Property projectName = project.objects.property(String).convention(project.name) + + @Input + final abstract Property projectVersion = project.objects.property(String).convention(project.getVersion().toString()) + + @Input + final abstract Property distributionVersion = project.objects.property(String).convention(BuildUtils.getDistributionVersion(project)) + + // The directory the distribution subdirectories are created in, from the 'dist' extension + @Internal + final abstract DirectoryProperty distributionsDir = project.objects.directoryProperty().fileValue(findDistributionsDir(project)) + + @Internal + final abstract DirectoryProperty buildDir = project.objects.directoryProperty().convention(project.layout.buildDirectory) + + @OutputDirectory + // we use a common directory to save on disk space for TeamCity. + final abstract DirectoryProperty modulesDir = project.objects.directoryProperty().fileValue(new File("${BuildUtils.getRootBuildDirPath(project)}/distModules")) + + @OutputFile + final abstract RegularFileProperty distributionPropertiesFile = project.objects.fileProperty().fileValue(BuildUtils.getBuildDirFile(project, DistributionExtension.DIST_PROPERTIES_FILE_NAME)) + + // Files from 'server/configs/webapps' are preferred, if this directory exists + @Internal + final abstract DirectoryProperty serverConfigsDir = project.objects.directoryProperty().fileValue(project.rootProject.file("server/configs/webapps/")) + + // Allows distributions to include a custom README + @Internal + final abstract DirectoryProperty resourcesDir = project.objects.directoryProperty().fileValue(project.file("resources")) + + @InputFiles + @PathSensitive(PathSensitivity.NAME_ONLY) + final abstract ConfigurableFileCollection distributionModules = project.objects.fileCollection().from(project.configurations.distribution) + + // Not an input because it is only resolved when the embedded server jar has to be built + @Internal + final abstract ConfigurableFileCollection embeddedServerJar = project.objects.fileCollection().from(project.configurations.embedded) + + // The api module patched with the commercial license libraries. Empty for an open source distribution. + @InputFiles + @PathSensitive(PathSensitivity.NAME_ONLY) + final abstract ConfigurableFileCollection patchedApiModule = project.objects.fileCollection() ModuleDistribution() { description = "Make a LabKey modules distribution" - distExtension = project.extensions.findByType(DistributionExtension.class) group = GroupNames.DISTRIBUTION Project serverProject = BuildUtils.getServerProject(project) this.dependsOn(serverProject.tasks.named("stageApp")) if (!BuildUtils.isOpenSource(project)) { - this.dependsOn(findLicensingProject().tasks.named("patchApiModule")) + TaskProvider patchApiTask = findLicensingProject(project).tasks.named("patchApiModule") + this.dependsOn(patchApiTask) + patchedApiModule.from(patchApiTask) } if (BuildUtils.embeddedProjectExists(project)) this.dependsOn(project.project(BuildUtils.getEmbeddedProjectPath(project.gradle)).tasks.named("build")) @@ -89,10 +141,7 @@ abstract class ModuleDistribution extends DefaultTask @OutputDirectory File getDistributionDir() { - if (distributionDir == null) { - distributionDir = project.file("${distExtension.dir}/" + getSubDir()) - } - return distributionDir + return new File(distributionsDir.get().asFile, getSubDir()) } @OutputFiles @@ -118,48 +167,46 @@ abstract class ModuleDistribution extends DefaultTask embeddedTomcatTarArchive() } - @OutputDirectory - File getModulesDir() + private static File findDistributionsDir(Project project) { - // we use a common directory to save on disk space for TeamCity. - return new File("${BuildUtils.getRootBuildDirPath(project)}/distModules") + return project.file(project.extensions.getByType(DistributionExtension.class).dir) } - Project findLicensingProject() + static Project findLicensingProject(Project project) { - if (licensingProject == null) { - Project currProject = project - while (licensingProject == null && currProject != null) { - if (currProject.plugins.findPlugin(ApplyLicenses)) - licensingProject = currProject - currProject = currProject.parent - } - - if (!BuildUtils.isOpenSource(project) && licensingProject == null) - throw new GradleException("Cannot build non-open source distribution. Unable to find project with the plugin org.labkey.build.applyLicenses in ${project.path} ancestors.") + Project licensingProject = null + Project currProject = project + while (licensingProject == null && currProject != null) { + if (currProject.plugins.findPlugin(ApplyLicenses)) + licensingProject = currProject + currProject = currProject.parent } + + if (!BuildUtils.isOpenSource(project) && licensingProject == null) + throw new GradleException("Cannot build non-open source distribution. Unable to find project with the plugin org.labkey.build.applyLicenses in ${project.path} ancestors.") + return licensingProject } private void gatherModules() { - File modulesDir = getModulesDir() - modulesDir.deleteDir() + File modulesDirFile = modulesDir.get().asFile + modulesDirFile.deleteDir() fs.copy { CopySpec copy -> - copy.from { project.configurations.distribution } + copy.from distributionModules copy.setDuplicatesStrategy(DuplicatesStrategy.EXCLUDE) - copy.into modulesDir + copy.into modulesDirFile } - if (!BuildUtils.isOpenSource(project)) + if (!isOpenSource.get()) { fs.copy { CopySpec copy -> - copy.from(findLicensingProject().tasks.patchApiModule.outputs.files.singleFile) + copy.from(patchedApiModule.singleFile) copy.rename { String fileName -> fileName.replace("-extJsCommercial", "") } - copy.into modulesDir + copy.into modulesDirFile copy.setDuplicatesStrategy(DuplicatesStrategy.INCLUDE) } } @@ -175,7 +222,7 @@ abstract class ModuleDistribution extends DefaultTask { if (archiveName == null) { - archiveName = "${archivePrefix}${BuildUtils.getDistributionVersion(project)}" + getFileIdentifier() + archiveName = "${archivePrefix}${distributionVersion.get()}" + getFileIdentifier() } return archiveName } @@ -193,18 +240,18 @@ abstract class ModuleDistribution extends DefaultTask // Standard name to use when extraFileIdentifier or subDirName property isn't provided private String getDefaultName() { - int idx = project.name.indexOf("_dist") - return idx == -1 ? project.name : project.name.substring(0, idx) + int idx = projectName.get().indexOf("_dist") + return idx == -1 ? projectName.get() : projectName.get().substring(0, idx) } private String getLabKeyServerJarPath() { - return new File(getModulesDir(), "labkeyServer.jar").path + return new File(modulesDir.get().asFile, "labkeyServer.jar").path } private String getDistributionZipPath() { - return new File(getModulesDir(), "labkey/distribution.zip").path + return new File(modulesDir.get().asFile, "labkey/distribution.zip").path } private String getTarArchivePath() @@ -214,18 +261,19 @@ abstract class ModuleDistribution extends DefaultTask private makeEmbeddedTomcatJar() { - File embeddedJarFile = project.configurations.embedded.singleFile + File embeddedJarFile = embeddedServerJar.singleFile String modulesZipFile = getDistributionZipPath() File serverJarFile = new File(getLabKeyServerJarPath()) + String buildDirPath = buildDir.get().asFile.path ant.zip(destFile: modulesZipFile) { - zipfileset(dir: getModulesDir(), + zipfileset(dir: modulesDir.get().asFile, prefix: "modules") { include(name: "*.module") } - zipfileset(dir: "${BuildUtils.getBuildDirPath(project)}/") { + zipfileset(dir: "${buildDirPath}/") { include(name: "labkeywebapp/**") } - zipfileset(dir: "${BuildUtils.getBuildDirPath(project)}/", + zipfileset(dir: "${buildDirPath}/", prefix: "${DistributionExtension.DIST_FILE_DIR}") { include(name: DistributionExtension.DIST_PROPERTIES_FILE_NAME) } @@ -234,17 +282,17 @@ abstract class ModuleDistribution extends DefaultTask fs.copy { CopySpec copy -> copy.from(embeddedJarFile) - copy.into(project.layout.buildDirectory) + copy.into(buildDir) copy.rename(embeddedJarFile.getName(), serverJarFile.getName()) copy.setDuplicatesStrategy(DuplicatesStrategy.INCLUDE) } ant.jar( - destfile: BuildUtils.getBuildDirFile(project, serverJarFile.getName()), + destfile: new File(buildDir.get().asFile, serverJarFile.getName()), update: true, keepcompression: true ) { - fileset(dir: "${getModulesDir()}", includes: "labkey/**") + fileset(dir: "${modulesDir.get().asFile}", includes: "labkey/**") } } @@ -254,12 +302,13 @@ abstract class ModuleDistribution extends DefaultTask if (!serverJarFile.exists()) makeEmbeddedTomcatJar() + String buildDirPath = buildDir.get().asFile.path ant.tar(tarfile: getTarArchivePath(), longfile: "gnu", compression: "gzip") { - tarfileset(dir: BuildUtils.getBuildDir(project), prefix: archiveName) { include(name: serverJarFile.getName()) } + tarfileset(dir: buildDir.get().asFile, prefix: getArchiveName()) { include(name: serverJarFile.getName()) } - tarfileset(dir: "${BuildUtils.getBuildDirPath(project)}/embedded", prefix: archiveName) + tarfileset(dir: "${buildDirPath}/embedded", prefix: getArchiveName()) } } @@ -267,39 +316,33 @@ abstract class ModuleDistribution extends DefaultTask { writeDistributionPropertiesFile() // Prefer files from 'server/configs/webapps' if they exist - File serverConfigDir = project.rootProject.file("server/configs/webapps/") + File serverConfigDir = serverConfigsDir.get().asFile if (serverConfigDir.exists()) { fs.copy({ CopySpec copy -> copy.from(serverConfigDir) copy.exclude "*.xml" - copy.into(project.layout.buildDirectory) + copy.into(buildDir) copy.setDuplicatesStrategy(DuplicatesStrategy.INCLUDE) }) } // Allow distributions to include custom README - File resources = project.file("resources") + File resources = resourcesDir.get().asFile if (resources.isDirectory()) { fs.copy({ CopySpec copy -> copy.from(resources) - copy.into(project.layout.buildDirectory) + copy.into(buildDir) copy.setDuplicatesStrategy(DuplicatesStrategy.INCLUDE) }) fs.copy({ CopySpec copy -> copy.from(resources) - copy.into(project.layout.buildDirectory.file("embedded")) + copy.into(buildDir.dir("embedded")) copy.setDuplicatesStrategy(DuplicatesStrategy.INCLUDE) }) } // This is necessary for reasons that are unclear. Without it, you get: // -bash: ./manual-upgrade.sh: /bin/sh^M: bad interpreter: No such file or directory // even though the original file has unix line endings. Dunno. - this.ant.fixcrlf (srcdir: BuildUtils.getBuildDirPath(project), includes: "manual-upgrade.sh", eol: "unix") - } - - @OutputFile - File getDistributionPropertiesFile() - { - return BuildUtils.getBuildDirFile(project, DistributionExtension.DIST_PROPERTIES_FILE_NAME) + this.ant.fixcrlf (srcdir: buildDir.get().asFile.path, includes: "manual-upgrade.sh", eol: "unix") } // Write distribution build properties and (if provided) dist.extraProperties map into distribution.properties. This @@ -310,14 +353,14 @@ abstract class ModuleDistribution extends DefaultTask // Assume that fileIdentifier (usually '-' + project.name, but not guaranteed) is the canonical name extraProperties.put("name", StringUtils.removeStart(getFileIdentifier(), '-')) extraProperties.put("filename", getArchiveName() + "." + DistributionExtension.TAR_ARCHIVE_EXTENSION) - extraProperties.put("version", project.version) + extraProperties.put("version", projectVersion.get()) // Include TeamCity buildUrl, if present. def buildUrl = StringUtils.trimToNull(System.getenv("BUILD_URL")) if (buildUrl != null) extraProperties.put("buildUrl", buildUrl) - getDistributionPropertiesFile().withWriter { out -> + distributionPropertiesFile.get().asFile.withWriter { out -> extraProperties.each { k, v -> out.println "${k}: ${v}" } } } diff --git a/src/main/groovy/org/labkey/gradle/task/PatchApiModule.groovy b/src/main/groovy/org/labkey/gradle/task/PatchApiModule.groovy new file mode 100644 index 00000000..6751cfbb --- /dev/null +++ b/src/main/groovy/org/labkey/gradle/task/PatchApiModule.groovy @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2026 LabKey Corporation + * + * 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 + * + * http://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 org.labkey.gradle.task + +import org.gradle.api.file.ArchiveOperations +import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.file.CopySpec +import org.gradle.api.file.DuplicatesStrategy +import org.gradle.api.tasks.CacheableTask +import org.gradle.api.tasks.InputFiles +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.bundling.Jar +import org.gradle.api.tasks.util.PatternFilterable + +import javax.inject.Inject + +/** + * Produces a copy of a module archive in which the open-source ExtJS libraries are replaced by their + * commercial-license counterparts. The archives to combine are provided as input file collections, + * which are not resolved until the task executes. + */ +@CacheableTask +abstract class PatchApiModule extends Jar +{ + @Inject abstract ArchiveOperations getArchiveOps() + + /** Archives containing the commercial ExtJS 3 libraries, in a top-level ext-3.x.y directory */ + @InputFiles + @PathSensitive(PathSensitivity.NONE) + abstract ConfigurableFileCollection getExtJs3Archives() + + /** Archives containing the commercial ExtJS 4 libraries, in a top-level ext-4.x.y directory */ + @InputFiles + @PathSensitive(PathSensitivity.NONE) + abstract ConfigurableFileCollection getExtJs4Archives() + + /** The module archives to be patched with the commercial libraries */ + @InputFiles + @PathSensitive(PathSensitivity.NONE) + abstract ConfigurableFileCollection getModuleArchives() + + PatchApiModule() + { + // first include the ext-3.4.1 and ext-4.2.1 directories from the extjs configuration artifacts + into('web', { CopySpec spec -> + spec.from({ extJs3Archives.files.collect { archiveOps.zipTree(it) } }) + }) + into('web', { CopySpec spec -> + spec.from({ extJs4Archives.files.collect { archiveOps.zipTree(it) } }) + }) + // include the original module file ... + from({ + moduleArchives.files.collect { + archiveOps.zipTree(it).matching({ PatternFilterable pattern -> + // DuplicatesStrategy.EXCLUDE doesn't seem to work in some environments + pattern.exclude('web/ext-*/**') + }) + } + }) + // ... but don't use the ext directories that come from that file + setDuplicatesStrategy(DuplicatesStrategy.EXCLUDE) + } +} diff --git a/src/main/groovy/org/labkey/gradle/task/RestoreFromTrash.groovy b/src/main/groovy/org/labkey/gradle/task/RestoreFromTrash.groovy index dfd4888b..794af0db 100644 --- a/src/main/groovy/org/labkey/gradle/task/RestoreFromTrash.groovy +++ b/src/main/groovy/org/labkey/gradle/task/RestoreFromTrash.groovy @@ -51,11 +51,11 @@ class RestoreFromTrash extends DefaultTask final abstract Property isDryRun = project.objects.property(Boolean).convention(project.hasProperty(PurgeArtifacts.DRY_RUN_PROPERTY)) @Input - final abstract Property artifactoryUrl = project.objects.property(String).convention((String) project.property(ARTIFACTORY_CONTEXT_URL_PROP)) + final abstract Property artifactoryUrl = project.objects.property(String).convention((String) project.property(BuildUtils.ARTIFACTORY_CONTEXT_URL_PROP)) @Input - final abstract Property artifactoryUser = project.objects.property(String).convention((String) project.property(ARTIFACTORY_USER_PROP)) + final abstract Property artifactoryUser = project.objects.property(String).convention((String) project.property(BuildUtils.ARTIFACTORY_USER_PROP)) @Input - final abstract Property artifactoryPassword = project.objects.property(String).convention((String) project.property(ARTIFACTORY_PASSWORD_PROP)) + final abstract Property artifactoryPassword = project.objects.property(String).convention((String) project.property(BuildUtils.ARTIFACTORY_PASSWORD_PROP)) private static final String NUM_NOT_FOUND = "numNotFound" private static final String NUM_RESTORED = "numRestored" diff --git a/src/main/groovy/org/labkey/gradle/task/RunTestSuite.groovy b/src/main/groovy/org/labkey/gradle/task/RunTestSuite.groovy index 9816165d..74258cca 100644 --- a/src/main/groovy/org/labkey/gradle/task/RunTestSuite.groovy +++ b/src/main/groovy/org/labkey/gradle/task/RunTestSuite.groovy @@ -20,6 +20,7 @@ import org.gradle.api.tasks.Internal import org.gradle.api.tasks.UntrackedTask import org.labkey.gradle.plugin.TeamCity import org.labkey.gradle.plugin.extension.TeamCityExtension +import org.labkey.gradle.plugin.extension.UiTestExtension import org.labkey.gradle.util.DatabaseProperties /** @@ -50,7 +51,7 @@ abstract class RunTestSuite extends RunUiTest } } - protected void setTeamCityProperties() + protected void configureTeamCityProperties(UiTestExtension testExt) { if (TeamCityExtension.isOnTeamCity(project)) { diff --git a/src/main/groovy/org/labkey/gradle/task/RunUiTest.groovy b/src/main/groovy/org/labkey/gradle/task/RunUiTest.groovy index 219692bd..3fa64a0f 100644 --- a/src/main/groovy/org/labkey/gradle/task/RunUiTest.groovy +++ b/src/main/groovy/org/labkey/gradle/task/RunUiTest.groovy @@ -30,14 +30,13 @@ import org.labkey.gradle.util.BuildUtils abstract class RunUiTest extends Test { public static final String LOG_DIR = "test/logs" - protected UiTestExtension testExt RunUiTest() { testLogging.showStandardStreams = true - testExt = (UiTestExtension) project.getExtensions().getByType(UiTestExtension.class) - setSystemProperties() - setJvmArgs() + UiTestExtension testExt = (UiTestExtension) project.getExtensions().getByType(UiTestExtension.class) + configureSystemProperties(testExt) + configureJvmArgs(testExt) reports { TestTaskReports -> reports reports.junitXml.required = false @@ -49,10 +48,9 @@ abstract class RunUiTest extends Test setTestClassesDirs (project.sourceSets.uiTest.output.classesDirs) ignoreFailures = true // Failing tests should not cause task to fail - outputs.upToDateWhen( { return false }) // always run tests when asked to } - void setJvmArgs() + void configureJvmArgs(UiTestExtension testExt) { List jvmArgsList = ["-Xmx512m", "-agentlib:jdwp=transport=dt_socket,server=y," + @@ -76,7 +74,7 @@ abstract class RunUiTest extends Test jvmArgs jvmArgsList } - protected void setSystemProperties() + protected void configureSystemProperties(UiTestExtension testExt) { Properties testConfig = testExt.getConfig() for (String key : testConfig.keySet()) @@ -98,10 +96,10 @@ abstract class RunUiTest extends Test systemProperty "user.home", System.getProperty('user.home') systemProperty "test.credentials.file", "${project.projectDir}/test.credentials.json" - setTeamCityProperties() + configureTeamCityProperties(testExt) } - protected void setTeamCityProperties() { + protected void configureTeamCityProperties(UiTestExtension testExt) { // do nothing by default } } diff --git a/src/main/groovy/org/labkey/gradle/task/SetUpProperties.groovy b/src/main/groovy/org/labkey/gradle/task/SetUpProperties.groovy index 973d1696..9d11a025 100644 --- a/src/main/groovy/org/labkey/gradle/task/SetUpProperties.groovy +++ b/src/main/groovy/org/labkey/gradle/task/SetUpProperties.groovy @@ -63,7 +63,7 @@ abstract class SetUpProperties extends TeamCityPropertiesTask @Input final abstract Property portNumber = project.objects.property(String).convention(project.hasProperty("useSsl") ? "8443" : "8080") @Input - final abstract Property useLocalBuild = project.objects.property(Boolean).convention(project.hasProperty("useLocalBuild") && "false" != project.property("useLocalBuild")) + final abstract Property useLocalBuild = project.objects.property(Boolean).convention(BuildUtils.useLocalBuild(project)) @Input // in .properties files, backward slashes are seen as escape characters, so all paths must use forward slashes, even on Windows final abstract Property pathToServer = project.objects.property(String).convention(project.rootDir.getAbsolutePath().replaceAll("\\\\", "/")) diff --git a/src/main/groovy/org/labkey/gradle/task/StartLabKey.groovy b/src/main/groovy/org/labkey/gradle/task/StartLabKey.groovy index 4b4bc9ee..cf0ef29a 100644 --- a/src/main/groovy/org/labkey/gradle/task/StartLabKey.groovy +++ b/src/main/groovy/org/labkey/gradle/task/StartLabKey.groovy @@ -21,6 +21,8 @@ import org.gradle.api.GradleException import org.gradle.api.Project import org.gradle.api.file.DirectoryProperty import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.ListProperty +import org.gradle.api.tasks.Input import org.gradle.api.tasks.InputDirectory import org.gradle.api.tasks.OutputFile import org.gradle.api.tasks.PathSensitive @@ -55,6 +57,12 @@ abstract class StartLabKey extends TeamCityPropertiesTask @OutputFile final abstract RegularFileProperty logFileProp = project.objects.fileProperty().convention(ServerDeployExtension.getEmbeddedServerDeployDirectory(project).file(Tomcat.EMBEDDED_LOG_FILE_NAME)) + @Input + final abstract ListProperty startupOpts = project.objects.listProperty(String).convention(getStartupOpts(project)) + + @Input + final abstract ListProperty embeddedReflectionOpts = project.objects.listProperty(String).convention(getReflectionOptions(project)) + @TaskAction void action() { @@ -73,8 +81,8 @@ abstract class StartLabKey extends TeamCityPropertiesTask if (!javaExec.exists()) throw new GradleException("Invalid value for JAVA_HOME. Could not find java command in ${javaExec}") String[] commandParts = [javaExec.getAbsolutePath()] - commandParts += getEmbeddedReflectionOpts(project) - commandParts += getStartupOpts(project) + commandParts += embeddedReflectionOpts.get() + commandParts += startupOpts.get() commandParts += ["-jar", jarFile.getName()] File logFile = logFileProp.get().asFile @@ -130,10 +138,10 @@ abstract class StartLabKey extends TeamCityPropertiesTask } - private static List getEmbeddedReflectionOpts(Project project) + private static List getReflectionOptions(Project project) { if (project.hasProperty(EMBEDDED_REFLECTION_PARAM)) { - return ((String) project.property(EMBEDDED_REFLECTION_PARAM)).trim().split("\\s+") + return List.of(((String) project.property(EMBEDDED_REFLECTION_PARAM)).trim().split("\\s+")) } else { return DEFAULT_EMBEDDED_REFLECTION_OPTS diff --git a/src/main/groovy/org/labkey/gradle/task/SymlinkNode.groovy b/src/main/groovy/org/labkey/gradle/task/SymlinkNode.groovy new file mode 100644 index 00000000..3a023f78 --- /dev/null +++ b/src/main/groovy/org/labkey/gradle/task/SymlinkNode.groovy @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2026 LabKey Corporation + * + * 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 + * + * http://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 org.labkey.gradle.task + +import org.gradle.api.DefaultTask +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Internal +import org.gradle.api.tasks.TaskAction +import org.gradle.api.tasks.UntrackedTask + +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths + +/** + * Creates symbolic links to the npm and node directories of the node bin project so they can be used in the PATH + * environment variable. Not applicable on Windows, where creating symbolic links requires elevated permissions. + */ +@UntrackedTask(because="Symbolic links are not tracked as task outputs") +abstract class SymlinkNode extends DefaultTask +{ + private static final String PACKAGE_MANAGER = "npm" + + /** The directory the symbolic links are created in */ + @Internal + abstract DirectoryProperty getLinkContainerDir() + + /** The directory containing the versioned npm directory the npm link points to */ + @Internal + abstract DirectoryProperty getNpmTargetDir() + + /** The directory containing the versioned node directory the node link points to */ + @Internal + abstract DirectoryProperty getNodeTargetDir() + + @Internal + abstract Property getNpmVersion() + + @Internal + abstract Property getNodeVersion() + + @TaskAction + void action() + { + if (!linkContainerDir.isPresent() || !npmVersion.isPresent() || !npmTargetDir.isPresent()) + { + logger.info("Symbolic links not created because the npm properties or the node bin project were not found.") + return + } + + File linkContainer = linkContainerDir.get().asFile + linkContainer.mkdirs() + + Path pmLinkPath = Paths.get("${linkContainer.getPath()}/${PACKAGE_MANAGER}") + String pmDirName = "${PACKAGE_MANAGER}-v${npmVersion.get()}" + Path pmTargetPath = Paths.get(new File(npmTargetDir.get().asFile, pmDirName).getPath()) + + if (!Files.isSymbolicLink(pmLinkPath) || !Files.readSymbolicLink(pmLinkPath).getFileName().toString().equals(pmDirName)) + { + // if the symbolic link exists, we want to replace it + if (Files.isSymbolicLink(pmLinkPath)) + Files.delete(pmLinkPath) + + Files.createSymbolicLink(pmLinkPath, pmTargetPath) + } + + String nodeFilePrefix = "node-v${nodeVersion.get()}-" + Path nodeLinkPath = Paths.get("${linkContainer.getPath()}/node") + if (!Files.isSymbolicLink(nodeLinkPath) || !Files.readSymbolicLink(nodeLinkPath).getFileName().toString().startsWith(nodeFilePrefix)) + { + if (!nodeTargetDir.isPresent()) + { + logger.warn("No node work directory found. Symbolic link in ${linkContainer.getPath()}/node not created.") + return + } + File nodeDir = nodeTargetDir.get().asFile + File[] nodeFiles = nodeDir.listFiles({ File file -> file.name.startsWith(nodeFilePrefix) } as FileFilter) + if (nodeFiles != null && nodeFiles.length > 0) + { + // if the symbolic link exists, we want to replace it + if (Files.isSymbolicLink(nodeLinkPath)) + Files.delete(nodeLinkPath) + + Files.createSymbolicLink(nodeLinkPath, nodeFiles[0].toPath()) + } + else + logger.warn("No file found with prefix ${nodeDir.path}/${nodeFilePrefix}. Symbolic link in ${linkContainer.getPath()}/node not created.") + } + } +} diff --git a/src/main/groovy/org/labkey/gradle/task/UndeployModule.groovy b/src/main/groovy/org/labkey/gradle/task/UndeployModule.groovy new file mode 100644 index 00000000..1ffde89b --- /dev/null +++ b/src/main/groovy/org/labkey/gradle/task/UndeployModule.groovy @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2026 LabKey Corporation + * + * 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 + * + * http://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 org.labkey.gradle.task + +import org.gradle.api.DefaultTask +import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.file.DeleteSpec +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.FileSystemOperations +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Internal +import org.gradle.api.tasks.TaskAction +import org.gradle.api.tasks.UntrackedTask +import org.labkey.gradle.plugin.Api +import org.labkey.gradle.plugin.FileModule +import org.labkey.gradle.plugin.ServerDeploy +import org.labkey.gradle.plugin.extension.ServerDeployExtension +import org.labkey.gradle.util.BuildUtils + +import javax.inject.Inject + +/** + * Removes a module's .module file and the directory it was unjarred into from the deploy directory, as well as its + * .module file in the staging directory and its api jar files. + */ +@UntrackedTask(because="Does only file removal") +abstract class UndeployModule extends DefaultTask +{ + @Inject abstract FileSystemOperations getFs() + + @Internal + final abstract Property moduleName = project.objects.property(String).convention(project.name) + + @Internal + final abstract DirectoryProperty deployModulesDir = project.objects.directoryProperty().fileValue(new File(ServerDeployExtension.getModulesDeployDirectory(project))) + + @Internal + final abstract DirectoryProperty stagingModulesDir = BuildUtils.getRootBuildDirectoryProperty(project, ServerDeploy.STAGING_MODULES_DIR) + + // It may seem proper to make this deletion part of the project's clean task since the jar file is put there by the + // build task, but since the copy is more of a deployment task than a build task and removing it will affect the + // running server, we do it here instead + @Internal + final abstract ConfigurableFileCollection modulesApiJars = project.objects.fileCollection().from(Api.getModulesApiJars(project)) + + @Internal + final abstract Property useLocalBuild = project.objects.property(Boolean).convention(BuildUtils.useLocalBuild(project)) + + @Internal + final abstract RegularFileProperty restartTriggerFile = project.objects.fileProperty().fileValue(BuildUtils.getRestartTriggerFile(project)) + + @TaskAction + void action() + { + // the files are deleted one at a time, and in this order, because the deploy directory for a module can be + // recreated by listeners if its .module file is still present when the directory is deleted + FileModule.getModuleFilesAndDirectories(moduleName.get(), deployModulesDir.get().asFile, stagingModulesDir.get().asFile) + .forEach({ File file -> + logger.info("Deleting ${file}") + fs.delete({ DeleteSpec spec -> spec.delete(file) }) + }) + fs.delete({ DeleteSpec spec -> spec.delete(modulesApiJars) }) + BuildUtils.updateRestartTriggerFile(useLocalBuild.get(), restartTriggerFile.get().asFile) + } +} diff --git a/src/main/groovy/org/labkey/gradle/task/UndeployModules.groovy b/src/main/groovy/org/labkey/gradle/task/UndeployModules.groovy index afaa002b..f0fc8c55 100644 --- a/src/main/groovy/org/labkey/gradle/task/UndeployModules.groovy +++ b/src/main/groovy/org/labkey/gradle/task/UndeployModules.groovy @@ -17,6 +17,8 @@ package org.labkey.gradle.task import org.gradle.api.DefaultTask import org.gradle.api.Project +import org.gradle.api.file.DeleteSpec +import org.gradle.api.file.FileSystemOperations import org.gradle.api.tasks.Input import org.gradle.api.tasks.Optional import org.gradle.api.tasks.TaskAction @@ -24,33 +26,42 @@ import org.gradle.api.tasks.UntrackedTask import org.labkey.gradle.plugin.FileModule import org.labkey.gradle.plugin.JavaModule import org.labkey.gradle.plugin.Module +import org.labkey.gradle.plugin.ServerDeploy +import org.labkey.gradle.plugin.extension.ModuleExtension +import org.labkey.gradle.plugin.extension.ServerDeployExtension +import org.labkey.gradle.util.BuildUtils + +import javax.inject.Inject /** - * Removes modules from the deploy and staging directories. If a value for dbType is provided, - * it removes those not supporting the given dbType. If dbType is null, removes all modules from - * the current set of projects. + * Removes all modules from the deploy and staging directories for the current set of projects */ @UntrackedTask(because="Does only file removal") -class UndeployModules extends DefaultTask +abstract class UndeployModules extends DefaultTask { - @Input @Optional - String dbType = null + @Inject abstract FileSystemOperations getFs() + + // The project tree is walked when this task is created because the projects are not available when it executes + private final List moduleInfos = findModuleInfos(project) @TaskAction void action() { + moduleInfos.forEach({ ModuleInfo module -> + this.logger.info("Undeploying module ${module.path}") + FileModule.getModuleFilesAndDirectories(module.name, module.deployDir, module.stagingDir) + .forEach({ File file -> fs.delete({ DeleteSpec spec -> spec.delete(file) }) }) + }) + } + + private static List findModuleInfos(Project project) + { + List moduleInfos = new ArrayList<>() project.rootProject.allprojects.each { Project p -> - if (isLabKeyModule(p) && - (dbType == null || !FileModule.shouldDoBuild(p, true) || !JavaModule.isDatabaseSupported(p, dbType))) - { - this.logger.info("Undeploying module ${p.path} for dbType ${dbType}") - FileModule.undeployModule(p) - } - else - { - this.logger.info("Module ${p.path} left in deployment for dbType ${dbType}") - } + if (isLabKeyModule(p)) + moduleInfos.add(new ModuleInfo(p)) } + return moduleInfos } static boolean isLabKeyModule(Project p) @@ -59,4 +70,25 @@ class UndeployModules extends DefaultTask p.plugins.findPlugin(Module.class) != null || p.plugins.findPlugin(FileModule.class) != null } + + /** + * The properties of a single module that are needed to undeploy it, captured when this task is created so no + * project is referenced while the task executes. + */ + static class ModuleInfo implements Serializable + { + final String path + final String name + final File deployDir + final File stagingDir + + ModuleInfo(Project project) + { + path = project.path + name = project.name + deployDir = new File(ServerDeployExtension.getModulesDeployDirectory(project)) + stagingDir = BuildUtils.getRootBuildDirFile(project, ServerDeploy.STAGING_MODULES_DIR) + } + + } } diff --git a/src/main/groovy/org/labkey/gradle/task/VerifyLicensePatch.groovy b/src/main/groovy/org/labkey/gradle/task/VerifyLicensePatch.groovy new file mode 100644 index 00000000..39bfd612 --- /dev/null +++ b/src/main/groovy/org/labkey/gradle/task/VerifyLicensePatch.groovy @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2026 LabKey Corporation + * + * 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 + * + * http://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 org.labkey.gradle.task + +import org.gradle.api.DefaultTask +import org.gradle.api.GradleException +import org.gradle.api.file.ArchiveOperations +import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.tasks.InputFile +import org.gradle.api.tasks.InputFiles +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction +import org.gradle.work.DisableCachingByDefault + +import javax.inject.Inject + +/** + * Verifies that the license files in a patched module archive match the license files in the + * commercial-license archives the module was patched with. + */ +@DisableCachingByDefault(because="Verification task that produces no output") +abstract class VerifyLicensePatch extends DefaultTask +{ + @Inject abstract ArchiveOperations getArchiveOps() + + /** The commercial-license archives, each of which is expected to contain a single license file */ + @InputFiles + @PathSensitive(PathSensitivity.NONE) + abstract ConfigurableFileCollection getCommercialArchives() + + /** The module archive that has been patched with the commercial-license libraries */ + @InputFile + @PathSensitive(PathSensitivity.NONE) + abstract RegularFileProperty getPatchedArchive() + + @TaskAction + void action() + { + File patchedFile = patchedArchive.get().asFile + commercialArchives.files.forEach({ File archive -> + File commercialLicense = archiveOps.zipTree(archive).matching { + it.include '*/license.txt' + }.singleFile + File patchedLicense = archiveOps.zipTree(patchedFile).matching { + it.include 'web/' + commercialLicense.parentFile.name + '/license.txt' + }.singleFile + if (commercialLicense.length() != patchedLicense.length()) { + throw new GradleException("License files didn't match for " + commercialLicense.parentFile.name) + } + }) + } +} diff --git a/src/main/groovy/org/labkey/gradle/task/WriteStartupProperties.groovy b/src/main/groovy/org/labkey/gradle/task/WriteStartupProperties.groovy new file mode 100644 index 00000000..50796e82 --- /dev/null +++ b/src/main/groovy/org/labkey/gradle/task/WriteStartupProperties.groovy @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2026 LabKey Corporation + * + * 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 + * + * http://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 org.labkey.gradle.task + +import org.apache.commons.io.FileUtils +import org.gradle.api.DefaultTask +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.Optional +import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.TaskAction +import org.gradle.work.DisableCachingByDefault + +import java.nio.charset.StandardCharsets + +/** + * Writes a startup properties file into the deployed server's startup directory. + */ +@DisableCachingByDefault(because="Output is in the deploy directory") +abstract class WriteStartupProperties extends DefaultTask +{ + @OutputFile + abstract RegularFileProperty getPropertiesFile() + + @Input @Optional + abstract Property getPropertiesContent() + + @TaskAction + void writeProperties() + { + File file = propertiesFile.get().asFile + String content = propertiesContent.getOrElse("") + if (content.isBlank()) + { + logger.info("No properties to write. ${file} not created.") + file.delete() + return + } + logger.info("Writing startup properties to ${file}") + FileUtils.write(file, content, StandardCharsets.UTF_8) + } +} diff --git a/src/main/groovy/org/labkey/gradle/util/BuildUtils.groovy b/src/main/groovy/org/labkey/gradle/util/BuildUtils.groovy index 986223a8..897e376e 100644 --- a/src/main/groovy/org/labkey/gradle/util/BuildUtils.groovy +++ b/src/main/groovy/org/labkey/gradle/util/BuildUtils.groovy @@ -912,7 +912,12 @@ class BuildUtils */ static void updateRestartTriggerFile(Project project) { - updateRestartTriggerFile(project.hasProperty('useLocalBuild') && "false" != project.property("useLocalBuild"), getRestartTriggerFile(project)) + updateRestartTriggerFile(useLocalBuild(project), getRestartTriggerFile(project)) + } + + static boolean useLocalBuild(Project project) + { + return project.hasProperty("useLocalBuild") && "false" != project.property("useLocalBuild") } static File getRestartTriggerFile(Project project) diff --git a/src/main/groovy/org/labkey/gradle/util/DatabaseProperties.groovy b/src/main/groovy/org/labkey/gradle/util/DatabaseProperties.groovy index 6fdb718e..55d49af8 100644 --- a/src/main/groovy/org/labkey/gradle/util/DatabaseProperties.groovy +++ b/src/main/groovy/org/labkey/gradle/util/DatabaseProperties.groovy @@ -21,7 +21,7 @@ import org.slf4j.LoggerFactory class DatabaseProperties { - Logger logger = LoggerFactory.getLogger(DatabaseProperties.class) + private static Logger logger = LoggerFactory.getLogger(DatabaseProperties.class) private static final String PICKED_DATABASE_CONFIG_FILE = "config.properties" private static final String JDBC_DRIVER_CLASS_NAME_PROP = "jdbcDriverClassName" diff --git a/src/main/groovy/org/labkey/gradle/util/ModuleFinder.groovy b/src/main/groovy/org/labkey/gradle/util/ModuleFinder.groovy index 242a4561..18c9a701 100644 --- a/src/main/groovy/org/labkey/gradle/util/ModuleFinder.groovy +++ b/src/main/groovy/org/labkey/gradle/util/ModuleFinder.groovy @@ -81,7 +81,7 @@ class ModuleFinder extends SimpleFileVisitor static boolean isModuleContainer(Project p) { - return (p.hasProperty("moduleContainer") && p.path.equalsIgnoreCase((String) p.property("moduleContainer"))) + return (p.ext.has("moduleContainer") && p.path.equalsIgnoreCase((String) p.ext.moduleContainer)) } static boolean isPotentialModule(Project p)