diff --git a/README.md b/README.md index ec80a5e4..13ed8787 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,17 @@ on how to do that, including how to develop and test locally and the versioning ## Release Notes +### TBD +*Released*: TBD +(Earliest compatible LabKey version: 26.7.0) +- Change logic for getting properties on TeamCity to be compatible with Gradle 10 +- More small updates in `CreateModule`, `InstallRPackage`, `RestoreFromTrash` and `SetUpProperties` for compatibility +- Add `listExternalDependencies` task to help in converting `showDiscrepancies` to be compatible with Gradle 10 +- Update to Gradle 9.8.0 +- Update `getStandardVCSProperties` to use non-deprecated command execution method +- Update check for `isOnTeamCity` to be more reliable +- Add new `runClientLibBuilds` task so clientLibs are automatically built if the enlistment exists + ### 11.0.0 *Released*: 15 September 2026 (Earliest compatible LabKey version: 26.7.0) diff --git a/build.gradle b/build.gradle index 63c2826d..cc6145f7 100644 --- a/build.gradle +++ b/build.gradle @@ -39,7 +39,7 @@ dependencies { } group = 'org.labkey.build' -project.version = "11.1.0-SNAPSHOT" +project.version = "11.1.0-teamCityProps-SNAPSHOT" gradlePlugin { plugins { diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index ad7845be..9b7cb4af 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.7.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.0-bin.zip networkTimeout=10000 retries=0 retryBackOffMs=500 diff --git a/src/main/groovy/org/labkey/gradle/plugin/LabKey.groovy b/src/main/groovy/org/labkey/gradle/plugin/LabKey.groovy index 187c0bb0..2a23c198 100644 --- a/src/main/groovy/org/labkey/gradle/plugin/LabKey.groovy +++ b/src/main/groovy/org/labkey/gradle/plugin/LabKey.groovy @@ -18,6 +18,8 @@ package org.labkey.gradle.plugin import org.gradle.api.Plugin import org.gradle.api.Project import org.labkey.gradle.plugin.extension.LabKeyExtension +import org.labkey.gradle.task.ListExternalDependencies +import org.labkey.gradle.util.GroupNames import org.labkey.gradle.util.ModuleFinder import org.labkey.gradle.util.BuildUtils @@ -30,6 +32,7 @@ class LabKey implements Plugin public static final String SOURCES_CLASSIFIER = "sources" public static final String JAVADOC_CLASSIFIER = "javadoc" public static final String FAT_JAR_CLASSIFIER = "all" + public static final String LIST_EXTERNAL_DEPENDENCIES_TASK = "listExternalDependencies" @Override void apply(Project project) @@ -44,6 +47,7 @@ class LabKey implements Plugin } addConfigurations(project) + addTasks(project) LabKeyExtension labKeyExt = project.extensions.create("labkey", LabKeyExtension) labKeyExt.setDirectories(project) @@ -72,6 +76,20 @@ class LabKey implements Plugin project.configurations.remotePipelineJars.setDescription("Dependencies required for running remote pipeline jobs") } + + private static void addTasks(Project project) + { + project.tasks.register(LIST_EXTERNAL_DEPENDENCIES_TASK, ListExternalDependencies) { + ListExternalDependencies task -> + task.group = GroupNames.HELP + task.description = "Lists the external module versions resolved for the 'external' configuration (used by the showDiscrepancies task)" + task.projectPath.set(project.path) + task.moduleVersions.set(project.configurations.named("external") + .flatMap { it.incoming.resolutionResult.rootComponent } + .map { ListExternalDependencies.getModuleVersions(it) }) + task.outputFile.set(project.layout.buildDirectory.file("externalDependencies.txt")) + } + } } diff --git a/src/main/groovy/org/labkey/gradle/plugin/NpmRun.groovy b/src/main/groovy/org/labkey/gradle/plugin/NpmRun.groovy index 0d5328bc..a653a1e4 100644 --- a/src/main/groovy/org/labkey/gradle/plugin/NpmRun.groovy +++ b/src/main/groovy/org/labkey/gradle/plugin/NpmRun.groovy @@ -21,6 +21,7 @@ import org.gradle.api.Project import org.gradle.api.Task import org.gradle.api.file.DeleteSpec import org.gradle.api.tasks.Delete +import org.gradle.api.tasks.PathSensitivity import org.gradle.api.tasks.TaskProvider import org.labkey.gradle.plugin.extension.LabKeyExtension import org.labkey.gradle.plugin.extension.NpmRunExtension @@ -41,8 +42,15 @@ class NpmRun implements Plugin public static final String NODE_MODULES_DIR = "node_modules" public static final String WEBPACK_DIR = "webpack" public static final String ENTRY_POINTS_FILE = "src/client/entryPoints.js" + public static final String UI_COMPONENTS = "labkey-ui-components" + public static final String UI_PREMIUM = "labkey-ui-premium" + public static final String JS_API = "labkey-api-js" + public static final String CLIENT_API_DIR = "clientAPIs" private static final String EXTENSION_NAME = "npmRun" + private static final String BUILD_CLIENT_LIBS_TASK = "npmRunBuildClientLibs" + private static final String NODE_PLUGIN_ID = "com.github.node-gradle.node" + private static final String NPM_TASK_CLASS = "com.github.gradle.node.npm.task.NpmTask" static boolean isApplicable(Project project) { @@ -58,7 +66,7 @@ class NpmRun implements Plugin void apply(Project project) { // This brings in nodeSetup and npmInstall tasks. See https://github.com/node-gradle/gradle-node-plugin - project.apply plugin: 'com.github.node-gradle.node' + project.apply plugin: NODE_PLUGIN_ID project.extensions.create(EXTENSION_NAME, NpmRunExtension) configurePlugin(project) @@ -130,7 +138,20 @@ class NpmRun implements Plugin task.mustRunAfter "npmInstall" } - configureBuildTask(project.tasks.named("npm_run_${project.npmRun.buildDev}")) + def npmRunBuildDevTask = project.tasks.named("npm_run_${project.npmRun.buildDev}") + configureBuildTask(npmRunBuildDevTask) + if (project.hasProperty("runClientLibBuilds")) { + List clientLibDirs = getClientLibDirs(project) + TaskProvider clientLibsTask = getClientLibsBuildTask(project, clientLibDirs) + if (clientLibsTask != null) + npmRunBuildDevTask.configure { Task task -> + task.dependsOn(clientLibsTask) + // Rebuild this module when the library build outputs change + task.inputs.files(clientLibDirs.collect { new File(it, "dist") }) + .withPropertyName("clientLibs") + .withPathSensitivity(PathSensitivity.RELATIVE) + } + } if (BuildUtils.useServerNode(project) && project.path !== BuildUtils.getServerProject(project).path) { project.tasks.named('npmSetup').configure { @@ -152,6 +173,88 @@ class NpmRun implements Plugin TaskUtils.configureTaskIfPresent(project, "processWebappResources", { dependsOn(runCommand) }) } + + /** + * @return the client library enlistments under clientAPIs, in dependency order + */ + private static List getClientLibDirs(Project project) + { + return [getJSAPIDir(project), getUIComponentsDir(project), getUIPremiumDir(project)].findAll { it != null } + } + + /** + * Returns the root project's task for building the client library enlistments under clientAPIs, registering + * it if this is the first module to ask for it. This way the libraries are built once per build, not once per module. + * The builds themselves are NpmTasks registered in the first module so they use the npm and node from that module's + * node configuration, which the root project doesn't have. + * @return the task provider, or null if there are no client library enlistments + */ + private static TaskProvider getClientLibsBuildTask(Project project, List clientLibDirs) + { + if (clientLibDirs.isEmpty()) + return null + + Project rootProject = project.rootProject + if (rootProject.tasks.names.contains(BUILD_CLIENT_LIBS_TASK)) + return rootProject.tasks.named(BUILD_CLIENT_LIBS_TASK) + + // The node plugin is loaded by the build script, which this plugin's classloader can't see, so get NpmTask from the plugin's classloader + Class npmTaskClass = project.plugins.getPlugin(NODE_PLUGIN_ID).class.classLoader.loadClass(NPM_TASK_CLASS) as Class + List> libTasks = [] + clientLibDirs.each { File libDir -> + List> previousLibTasks = new ArrayList<>(libTasks) + libTasks.add(project.tasks.register(getTaskNameFromDirName(libDir.name), npmTaskClass) { Task task -> + task.group = GroupNames.NPM_RUN + task.description = "Runs 'npm run build' in ${libDir}" + task.workingDir.set(libDir) + task.args.set(["run", "build"]) + task.dependsOn(previousLibTasks) + task.inputs.files(project.fileTree(dir: libDir, includes: ["src/**/*", NPM_PROJECT_FILE, NPM_PROJECT_LOCK_FILE, TYPESCRIPT_CONFIG_FILE, "package.config.js", "webpack.config.js"])) + .withPropertyName("clientLibSources") + .withPathSensitivity(PathSensitivity.RELATIVE) + task.outputs.dir(new File(libDir, "dist")) + .withPropertyName("clientLibDist") + }) + } + + return rootProject.tasks.register(BUILD_CLIENT_LIBS_TASK) { Task task -> + task.group = GroupNames.NPM_RUN + task.description = "Runs 'npm run build' in the client library enlistments under ${CLIENT_API_DIR}" + task.dependsOn(libTasks) + } + } + + private static String getTaskNameFromDirName(String dirName) + { + switch (dirName) { + case JS_API: return "npmRunBuild_api" + case UI_PREMIUM: return "npmRunBuild_premium" + default: return "npmRunBuild_" + dirName + } + } + + static File getUIComponentsDir(Project project) + { + return getClientLibsDir(project, UI_COMPONENTS + "/packages/components") + } + + static File getUIPremiumDir(Project project) + { + return getClientLibsDir(project, UI_PREMIUM) + } + + static File getJSAPIDir(Project project) + { + return getClientLibsDir(project, JS_API) + } + + static File getClientLibsDir(Project project, String clientLibName) { + File file = new File(project.rootProject.rootDir, "${CLIENT_API_DIR}/${clientLibName}") + if (file.exists()) + return file + return null + } + private static void addTasks(Project project) { if (project.file(NPM_PROJECT_FILE).exists()) diff --git a/src/main/groovy/org/labkey/gradle/plugin/TeamCity.groovy b/src/main/groovy/org/labkey/gradle/plugin/TeamCity.groovy index d0ca951d..0b65798c 100644 --- a/src/main/groovy/org/labkey/gradle/plugin/TeamCity.groovy +++ b/src/main/groovy/org/labkey/gradle/plugin/TeamCity.groovy @@ -105,13 +105,13 @@ class TeamCity extends Tomcat project.tasks.named("stopLabKey").configure { it.doLast { Task task -> - ensureShutdown(task.logger, debugPort) + TeamCity.ensureShutdown(task.logger, debugPort) } } project.tasks.named("stopTomcat").configure { it.doLast { Task task -> - ensureShutdown(task.logger, debugPort) + TeamCity.ensureShutdown(task.logger, debugPort) } } @@ -120,7 +120,7 @@ class TeamCity extends Tomcat task.group = GroupNames.TEST_SERVER task.description = "Kill Chrome processes" task.doLast { - killChrome(it.ant) + TeamCity.killChrome(it.ant) } } @@ -129,7 +129,7 @@ class TeamCity extends Tomcat task.group = GroupNames.TEST_SERVER task.description = "Kill Firefox processes" task.doLast { - killFirefox(it.ant) + TeamCity.killFirefox(it.ant) } } @@ -149,13 +149,19 @@ class TeamCity extends Tomcat project.tasks.register("validateConfiguration") { Task task -> + // Captured here because the extension (which references the project) cannot be used from a task + // action with the configuration cache + boolean isValid = extension.isValidForTestRun() + String validationMessages = extension.validationMessages.join('; ') + String branchIsDefault = extension.getTeamCityProperty('teamcity.build.branch.is_default') + String branch = extension.getTeamCityProperty('teamcity.build.branch') task.doFirst { - if (!extension.isValidForTestRun()) - throw new GradleException("TeamCity configuration problem(s): ${extension.validationMessages.join('; ')}") + if (!isValid) + throw new GradleException("TeamCity configuration problem(s): ${validationMessages}") - task.logger.info("teamcity.build.branch.is_default: ${extension.getTeamCityProperty('teamcity.build.branch.is_default')}") - task.logger.info("teamcity.build.branch: ${extension.getTeamCityProperty('teamcity.build.branch')}") + task.logger.info("teamcity.build.branch.is_default: ${branchIsDefault}") + task.logger.info("teamcity.build.branch: ${branch}") } } @@ -251,7 +257,7 @@ class TeamCity extends Tomcat task.description = "Run a test suite on the TeamCity server" task.doLast( { - killFirefox(task.ant) + TeamCity.killFirefox(task.ant) } ) } @@ -263,7 +269,9 @@ class TeamCity extends Tomcat } } - private static void killChrome(AntBuilder ant) + // Not private and called qualified by class name from task actions because, with the configuration cache, those + // closures are restored without this plugin as their owner, so unqualified calls would be looked up on the task + static void killChrome(AntBuilder ant) { if (SystemUtils.IS_OS_WINDOWS) { @@ -293,7 +301,7 @@ class TeamCity extends Tomcat } } - private static void killFirefox(AntBuilder ant) + static void killFirefox(AntBuilder ant) { if (SystemUtils.IS_OS_WINDOWS) { @@ -432,7 +440,7 @@ class TeamCity extends Tomcat } } - private static void ensureShutdown(Logger logger, String debugPort) + static void ensureShutdown(Logger logger, String debugPort) { if (!debugPort.isEmpty()) { 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 c5fb0815..f2b353fc 100644 --- a/src/main/groovy/org/labkey/gradle/plugin/extension/TeamCityExtension.groovy +++ b/src/main/groovy/org/labkey/gradle/plugin/extension/TeamCityExtension.groovy @@ -24,6 +24,8 @@ import java.nio.charset.StandardCharsets class TeamCityExtension { + public static final String TEAMCITY_VERSION_ENV_VAR = "TEAMCITY_VERSION" + String databaseName Boolean dropDatabase = false List databaseTypes = new ArrayList<>() @@ -37,15 +39,40 @@ class TeamCityExtension setValidationMessages() } + /** + * TeamCity's Gradle init script declares the 'teamcity' property on the root project. Look for it explicitly + * on the given project and then on the root project, since implicit lookup of properties in parent projects is + * deprecated (and will fail in Gradle 10). + * @param project the project whose TeamCity properties are wanted + * @return the map of TeamCity properties, or null if not running on TeamCity + */ + static Map getTeamCityMap(Project project) + { + def extraProperties = project.extensions.extraProperties + if (extraProperties.has('teamcity')) + return (Map) extraProperties.get('teamcity') + if (project != project.rootProject) + return (Map) project.rootProject.findProperty('teamcity') + return (Map) project.findProperty('teamcity') + } + + /** + * TeamCity sets the TEAMCITY_VERSION environment variable for all builds run on its agents. This is more reliable + * than checking for the 'teamcity' property, which TeamCity's Gradle init script does not always populate + * (for example, with some configuration cache setups). + * @param project the current project + * @return true if the build is running on a TeamCity agent + */ static boolean isOnTeamCity(Project project) { - return project.hasProperty('teamcity') + return project.providers.environmentVariable(TEAMCITY_VERSION_ENV_VAR).isPresent() } static Object getTeamCityProperty(Project project, String name, Object defaultValue) { - if (isOnTeamCity(project)) - return project.teamcity[name] != null ? project.teamcity[name] : defaultValue + Map teamcity = getTeamCityMap(project) + if (teamcity != null) + return teamcity[name] != null ? teamcity[name] : defaultValue else if (project.hasProperty(name)) return project.property(name) else @@ -54,10 +81,11 @@ class TeamCityExtension static Properties getTeamCityProperties(Project project) { - if (isOnTeamCity(project)) + Map teamcity = getTeamCityMap(project) + if (teamcity != null) { def tcProps = new Properties() - tcProps.putAll(project.teamcity) + tcProps.putAll(teamcity) return tcProps } else @@ -109,7 +137,7 @@ class TeamCityExtension return getTeamCityProperty(project, "tomcatJavaHome", System.getenv("JAVA_HOME")) } - Boolean isValidForTestRun() + boolean isValidForTestRun() { return validationMessages.isEmpty() } diff --git a/src/main/groovy/org/labkey/gradle/task/CreateModule.groovy b/src/main/groovy/org/labkey/gradle/task/CreateModule.groovy index 21b7e72a..c7d3cfa0 100644 --- a/src/main/groovy/org/labkey/gradle/task/CreateModule.groovy +++ b/src/main/groovy/org/labkey/gradle/task/CreateModule.groovy @@ -18,13 +18,16 @@ package org.labkey.gradle.task import org.apache.commons.lang3.StringUtils import org.gradle.api.DefaultTask import org.gradle.api.GradleException +import org.gradle.api.file.ArchiveOperations import org.gradle.api.file.CopySpec import org.gradle.api.file.DuplicatesStrategy +import org.gradle.api.file.FileSystemOperations import org.gradle.api.file.FileTree import org.gradle.api.tasks.TaskAction import org.gradle.api.tasks.UntrackedTask import org.labkey.gradle.util.BuildUtils +import javax.inject.Inject import java.util.regex.Matcher import java.util.regex.Pattern @@ -34,8 +37,26 @@ import java.util.regex.Pattern * Documented at labkey.org */ @UntrackedTask(because="Has user interaction and side effects") -class CreateModule extends DefaultTask +abstract class CreateModule extends DefaultTask { + private final File projectDir + private final String moduleNameProperty + private final String moduleDestinationProperty + private final String createFilesProperty + private final String labKeyModuleVersion + + @Inject abstract FileSystemOperations getFs() + @Inject abstract ArchiveOperations getArchiveOps() + + CreateModule() + { + projectDir = project.projectDir + moduleNameProperty = (String) project.findProperty('moduleName') + moduleDestinationProperty = (String) project.findProperty('moduleDestination') + createFilesProperty = (String) project.findProperty('createFiles') + labKeyModuleVersion = BuildUtils.getLabKeyModuleVersion(project.rootProject) + } + @TaskAction void createModule() { String moduleName @@ -45,8 +66,8 @@ class CreateModule extends DefaultTask boolean createApiFiles boolean deleteExisting - if (project.hasProperty('moduleName')) { - moduleName = project.moduleName + if (moduleNameProperty != null) { + moduleName = moduleNameProperty } else { ant.input( @@ -63,12 +84,12 @@ class CreateModule extends DefaultTask throw new GradleException("Invalid module name: " + moduleName) } - if (project.hasProperty('moduleDestination')) { - moduleDestination = project.moduleDestination + if (moduleDestinationProperty != null) { + moduleDestination = moduleDestinationProperty } else { ant.input( - message: "\nEnter the location for the new module (absolute or relative to '" + project.projectDir.getAbsolutePath() + "'): ", + message: "\nEnter the location for the new module (absolute or relative to '" + projectDir.getAbsolutePath() + "'): ", addProperty: "new_moduleDestination" ) moduleDestination = ant.new_moduleDestination.trim() @@ -76,7 +97,10 @@ class CreateModule extends DefaultTask if (moduleDestination == null || moduleDestination == "") { throw new GradleException("moduleDestination is not specified") } - File moduleDestinationFile = project.file(moduleDestination).getAbsoluteFile() + File moduleDestinationFile = new File(moduleDestination) + if (!moduleDestinationFile.isAbsolute()) + moduleDestinationFile = new File(projectDir, moduleDestination) + moduleDestinationFile = moduleDestinationFile.getAbsoluteFile() if (moduleDestinationFile.exists()) { ant.input( @@ -89,10 +113,10 @@ class CreateModule extends DefaultTask throw new GradleException("Select a different destination") } - if (project.hasProperty('createFiles')) { - hasManagedSchema = ((String)project.createFiles).contains('schema') - createTestFiles = ((String)project.createFiles).contains('test') - createApiFiles = ((String)project.createFiles).contains('api') + if (createFilesProperty != null) { + hasManagedSchema = createFilesProperty.contains('schema') + createTestFiles = createFilesProperty.contains('test') + createApiFiles = createFilesProperty.contains('api') } else { ant.input( @@ -135,7 +159,7 @@ class CreateModule extends DefaultTask throw new GradleException("Failed to create new module directory at ${moduleDestinationFile.getAbsolutePath()}") } - String[] versionParts = BuildUtils.getLabKeyModuleVersion(project.rootProject).split("\\.") + String[] versionParts = labKeyModuleVersion.split("\\.") Map substitutions = [ 'MODULE_DIR_NAME' : moduleName.toLowerCase(), 'MODULE_LOWERCASE_NAME' : moduleName.toLowerCase(), @@ -145,17 +169,17 @@ class CreateModule extends DefaultTask 'SCHEMA_VERSION_NUMBER': versionParts[0] + ".001" ] - project.copy({ CopySpec copy -> + fs.copy({ CopySpec copy -> // This seems a very convoluted way to get to the zip file in the jar file. Using the classLoader did not // work as expected, however. Following the example from here: // https://discuss.gradle.org/t/gradle-plugin-copy-directory-tree-with-files-from-resources/12767/7 - FileTree jarTree = project.zipTree(getClass().getProtectionDomain().getCodeSource().getLocation().toExternalForm()) + FileTree jarTree = archiveOps.zipTree(getClass().getProtectionDomain().getCodeSource().getLocation().toExternalForm()) File zipFile = jarTree.matching({ include "moduleTemplate.zip" }).singleFile - copy.from(project.zipTree(zipFile)) + copy.from(archiveOps.zipTree(zipFile)) copy.into(moduleDestinationFile) copy.setDuplicatesStrategy(DuplicatesStrategy.FAIL) if (hasManagedSchema) diff --git a/src/main/groovy/org/labkey/gradle/task/DeployAppBase.groovy b/src/main/groovy/org/labkey/gradle/task/DeployAppBase.groovy index c07e91da..7ff67278 100644 --- a/src/main/groovy/org/labkey/gradle/task/DeployAppBase.groovy +++ b/src/main/groovy/org/labkey/gradle/task/DeployAppBase.groovy @@ -16,6 +16,7 @@ package org.labkey.gradle.task import org.apache.commons.lang3.SystemUtils +import org.gradle.api.file.ArchiveOperations import org.gradle.api.file.ConfigurableFileCollection import org.gradle.api.file.CopySpec import org.gradle.api.file.DirectoryProperty @@ -34,6 +35,7 @@ import javax.inject.Inject abstract class DeployAppBase extends SetUpProperties { @Inject abstract FileSystemOperations getFs() + @Inject abstract ArchiveOperations getArchiveOps() @InputFiles @PathSensitive(PathSensitivity.RELATIVE) @@ -52,7 +54,7 @@ abstract class DeployAppBase extends SetUpProperties { fs.copy({ CopySpec copy -> copy.setDuplicatesStrategy(DuplicatesStrategy.EXCLUDE) - copy.from(getBinaries().collect { project.zipTree(it) }) + copy.from(getBinaries().collect { archiveOps.zipTree(it) }) copy.into deployBinDir.path }) this.logger.debug("Contents of ${deployBinDir}\n" + deployBinDir.listFiles()) diff --git a/src/main/groovy/org/labkey/gradle/task/InstallRPackage.groovy b/src/main/groovy/org/labkey/gradle/task/InstallRPackage.groovy index e56305e8..634e33c5 100644 --- a/src/main/groovy/org/labkey/gradle/task/InstallRPackage.groovy +++ b/src/main/groovy/org/labkey/gradle/task/InstallRPackage.groovy @@ -35,9 +35,13 @@ class InstallRPackage extends DefaultTask protected String rPath protected File rLibsUserDir + protected final File projectDir + protected final String rLibsUserPath InstallRPackage() { + projectDir = project.projectDir + rLibsUserPath = getRLibsUserPath(project) rPath = getRPath() rLibsUserDir = getInstallDir() if (rPath == null) @@ -61,14 +65,13 @@ class InstallRPackage extends DefaultTask @OutputDirectory File getInstallDir() { - String path = getRLibsUserPath(project) - return path == null ? null : new File(path) + return rLibsUserPath == null ? null : new File(rLibsUserPath) } @TaskAction void doInstall() { - project.mkdir("${getRLibsUserPath(project)}/logs") + new File("${rLibsUserPath}/logs").mkdirs() if (installScript != null) installRPackage(installScript) } @@ -77,8 +80,8 @@ class InstallRPackage extends DefaultTask { String exitCode = "" ant.exec(executable: rPath, - dir: project.projectDir, - input:project.file("check-installed.R"), + dir: projectDir, + input: new File(projectDir, "check-installed.R"), failifexecutionfails: true, searchpath: true, resultproperty: exitCode ) @@ -147,16 +150,16 @@ class InstallRPackage extends DefaultTask { ant.exec( executable: rPath, - dir: project.projectDir, + dir: projectDir, failifexecutionfails: false, searchpath: true, - input: "${project.projectDir}/${scriptName}", - output: "${getRLibsUserPath(project)}/logs/${scriptName}.log", + input: "${projectDir}/${scriptName}", + output: "${rLibsUserPath}/logs/${scriptName}.log", logError: true ) { arg(line: "--vanilla --no-multiarch") - env(key: "R_LIBS_USER", value: getRLibsUserPath(project)) // TODO is this actually necessary? + env(key: "R_LIBS_USER", value: rLibsUserPath) // TODO is this actually necessary? } } @@ -165,10 +168,10 @@ class InstallRPackage extends DefaultTask ant.exec( executable: rPath, - dir: getRLibsUserPath(project), + dir: rLibsUserPath, failifexecutionfails: true, searchpath: true, - output: "${getRLibsUserPath(project)}/logs/${archiveFileName}.log", + output: "${rLibsUserPath}/logs/${archiveFileName}.log", logError: true ) { diff --git a/src/main/groovy/org/labkey/gradle/task/JspCompile2Java.groovy b/src/main/groovy/org/labkey/gradle/task/JspCompile2Java.groovy index a135c8a4..ff67a675 100644 --- a/src/main/groovy/org/labkey/gradle/task/JspCompile2Java.groovy +++ b/src/main/groovy/org/labkey/gradle/task/JspCompile2Java.groovy @@ -87,7 +87,7 @@ abstract class JspCompile2Java extends DefaultTask File classesDir = classesDirectory.get().asFile if (!classesDir.mkdirs()) - throw new GradleException("${project.path}: problem creating output directory ${classesDir.getAbsolutePath()}") + throw new GradleException("${path}: problem creating output directory ${classesDir.getAbsolutePath()}") ant.taskdef( name: 'jasper', diff --git a/src/main/groovy/org/labkey/gradle/task/ListExternalDependencies.groovy b/src/main/groovy/org/labkey/gradle/task/ListExternalDependencies.groovy new file mode 100644 index 00000000..8ad6114f --- /dev/null +++ b/src/main/groovy/org/labkey/gradle/task/ListExternalDependencies.groovy @@ -0,0 +1,87 @@ +/* + * 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.artifacts.component.ModuleComponentIdentifier +import org.gradle.api.artifacts.result.DependencyResult +import org.gradle.api.artifacts.result.ResolvedComponentResult +import org.gradle.api.artifacts.result.ResolvedDependencyResult +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.ListProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.CacheableTask +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.TaskAction + +import java.nio.charset.StandardCharsets + +/** + * Writes the external module versions resolved for a project's 'external' configuration to a file, with the project + * path on the first line and one 'group:name:version' per line after that. These files are aggregated by the + * {@link ShowDiscrepancies} task. The resolution happens in the context of the owning project, which is required + * because a task may not resolve configurations of other projects. + */ +@CacheableTask +abstract class ListExternalDependencies extends DefaultTask +{ + @Input + abstract Property getProjectPath() + + @Input + abstract ListProperty getModuleVersions() + + @OutputFile + abstract RegularFileProperty getOutputFile() + + @TaskAction + void list() + { + List lines = [projectPath.get()] + lines.addAll(moduleVersions.get()) + outputFile.get().asFile.setText(lines.join("\n") + "\n", StandardCharsets.UTF_8.name()) + } + + /** + * @param root the root of a resolved dependency graph + * @return the sorted 'group:name:version' coordinates of all external modules in the graph + */ + static List getModuleVersions(ResolvedComponentResult root) + { + Set coordinates = new TreeSet<>() + Set visited = new HashSet<>() + Deque toVisit = new ArrayDeque<>() + toVisit.add(root) + while (!toVisit.isEmpty()) + { + ResolvedComponentResult component = toVisit.poll() + if (!visited.add(component)) + continue + if (component.id instanceof ModuleComponentIdentifier) + { + ModuleComponentIdentifier id = (ModuleComponentIdentifier) component.id + coordinates.add("${id.group}:${id.module}:${id.version}".toString()) + } + for (DependencyResult dependency : component.dependencies) + { + if (dependency instanceof ResolvedDependencyResult) + toVisit.add(((ResolvedDependencyResult) dependency).selected) + } + } + return new ArrayList<>(coordinates) + } +} diff --git a/src/main/groovy/org/labkey/gradle/task/RestoreFromTrash.groovy b/src/main/groovy/org/labkey/gradle/task/RestoreFromTrash.groovy index 794af0db..25d6e8dd 100644 --- a/src/main/groovy/org/labkey/gradle/task/RestoreFromTrash.groovy +++ b/src/main/groovy/org/labkey/gradle/task/RestoreFromTrash.groovy @@ -175,13 +175,13 @@ class RestoreFromTrash extends DefaultTask */ Response makeRestoreRequest(String artifactName, String version, String type) { - if (project.hasProperty("dryRun")) { + if (isDryRun.get()) { logger.quiet("\tRestoring version ${version} of ${artifactName} ${type} -- Skipped for dry run") return null } CloseableHttpClient httpClient = HttpClients.createDefault() - String endpoint = project.property(BuildUtils.ARTIFACTORY_CONTEXT_URL_PROP) + String endpoint = artifactoryUrl.get() Response responseStatus = Response.SUCCESS if (!endpoint.endsWith("/")) endpoint += "/" @@ -197,7 +197,7 @@ class RestoreFromTrash extends DefaultTask HttpPost httpPost = new HttpPost(endpoint) // N.B. Using Authorization Bearer with an API token does not currently work // TODO JFrog claims to support bearer tokens now. https://docs.jfrog.com/administration/docs/access-tokens#authorization-headers - httpPost.setHeader("Authorization", "Basic " + Base64.getEncoder().encodeToString("${project.property(BuildUtils.ARTIFACTORY_USER_PROP)}:${project.property(BuildUtils.ARTIFACTORY_PASSWORD_PROP)}".getBytes())) + httpPost.setHeader("Authorization", "Basic " + Base64.getEncoder().encodeToString("${artifactoryUser.get()}:${artifactoryPassword.get()}".getBytes())) CloseableHttpResponse response = httpClient.execute(httpPost) int statusCode = response.getCode() diff --git a/src/main/groovy/org/labkey/gradle/task/RunTestSuite.groovy b/src/main/groovy/org/labkey/gradle/task/RunTestSuite.groovy index 74258cca..7be2c4b4 100644 --- a/src/main/groovy/org/labkey/gradle/task/RunTestSuite.groovy +++ b/src/main/groovy/org/labkey/gradle/task/RunTestSuite.groovy @@ -53,30 +53,31 @@ abstract class RunTestSuite extends RunUiTest protected void configureTeamCityProperties(UiTestExtension testExt) { - if (TeamCityExtension.isOnTeamCity(project)) + Map teamcity = TeamCityExtension.getTeamCityMap(project) + if (teamcity != null) { - systemProperty "teamcity.tests.recentlyFailedTests.file", project.teamcity['teamcity.tests.recentlyFailedTests.file'] - systemProperty "teamcity.build.changedFiles.file", project.teamcity['teamcity.build.changedFiles.file'] - String runRiskGroupTestsFirst = project.teamcity['tests.runRiskGroupTestsFirst'] + systemProperty "teamcity.tests.recentlyFailedTests.file", teamcity['teamcity.tests.recentlyFailedTests.file'] + systemProperty "teamcity.build.changedFiles.file", teamcity['teamcity.build.changedFiles.file'] + String runRiskGroupTestsFirst = teamcity['tests.runRiskGroupTestsFirst'] if (runRiskGroupTestsFirst != null) { systemProperty "testNewAndModified", "${runRiskGroupTestsFirst.contains("newAndModified")}" systemProperty "testRecentlyFailed", "${runRiskGroupTestsFirst.contains("recentlyFailed")}" } - systemProperty "teamcity.buildType.id", project.teamcity['teamcity.buildType.id'] - systemProperty "tomcat.port", project.teamcity["tomcat.port"] - systemProperty "tomcat.debug", project.teamcity["tomcat.debug"] - systemProperty "labkey.port", project.teamcity['tomcat.port'] - systemProperty "maxTestFailures", project.teamcity['maxTestFailures'] - systemProperty 'test.credentials.file', project.teamcity['test.credentials.file'] - systemProperty 'testValidationOnly', project.teamcity['testValidationOnly'] + systemProperty "teamcity.buildType.id", teamcity['teamcity.buildType.id'] + systemProperty "tomcat.port", teamcity["tomcat.port"] + systemProperty "tomcat.debug", teamcity["tomcat.debug"] + systemProperty "labkey.port", teamcity['tomcat.port'] + systemProperty "maxTestFailures", teamcity['maxTestFailures'] + systemProperty 'test.credentials.file', teamcity['test.credentials.file'] + systemProperty 'testValidationOnly', teamcity['testValidationOnly'] Properties testConfig = testExt.getConfig() for (String key : testConfig.keySet()) { - if (!StringUtils.isEmpty((String) project.teamcity[key])) + if (!StringUtils.isEmpty((String) teamcity[key])) { - systemProperty key, project.teamcity[key] + systemProperty key, teamcity[key] } } } diff --git a/src/main/groovy/org/labkey/gradle/task/SetUpProperties.groovy b/src/main/groovy/org/labkey/gradle/task/SetUpProperties.groovy index 9d11a025..402ab00d 100644 --- a/src/main/groovy/org/labkey/gradle/task/SetUpProperties.groovy +++ b/src/main/groovy/org/labkey/gradle/task/SetUpProperties.groovy @@ -31,7 +31,6 @@ 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.UntrackedTask import org.gradle.work.DisableCachingByDefault import org.labkey.gradle.plugin.extension.TeamCityExtension import org.labkey.gradle.util.BuildUtils @@ -46,6 +45,8 @@ abstract class SetUpProperties extends TeamCityPropertiesTask @Internal private DatabaseProperties databaseProperties + private final Properties teamCityProperties = TeamCityExtension.getTeamCityProperties(project) + @InputFiles @Classpath abstract ConfigurableFileCollection getDriverFiles() @@ -137,7 +138,7 @@ abstract class SetUpProperties extends TeamCityPropertiesTask Properties getExtraJdbcProperties() { def extraJdbcProperties = new Properties() - def tcProperties = isOnTeamCity.get() ? TeamCityExtension.getTeamCityProperties(project) : new Properties() + def tcProperties = isOnTeamCity.get() ? teamCityProperties : new Properties() for (Map.Entry entry : tcProperties.entrySet()) { if (entry.getKey().startsWith("extraJdbc")) diff --git a/src/main/groovy/org/labkey/gradle/task/ShowDiscrepancies.groovy b/src/main/groovy/org/labkey/gradle/task/ShowDiscrepancies.groovy index 77996d0e..0a817550 100644 --- a/src/main/groovy/org/labkey/gradle/task/ShowDiscrepancies.groovy +++ b/src/main/groovy/org/labkey/gradle/task/ShowDiscrepancies.groovy @@ -17,52 +17,62 @@ package org.labkey.gradle.task import org.gradle.api.DefaultTask import org.gradle.api.Project -import org.gradle.api.artifacts.Configuration -import org.gradle.api.artifacts.ModuleVersionIdentifier -import org.gradle.api.artifacts.ResolvedArtifact +import org.gradle.api.file.ConfigurableFileCollection +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.api.tasks.UntrackedTask +import org.labkey.gradle.plugin.LabKey + +import java.nio.charset.StandardCharsets /** * This task will collect all the resolved dependencies from each project and print a report * that shows the external dependencies with more than one version referenced within the build. + * Each project's dependencies are resolved by its own {@link ListExternalDependencies} task, since a task may not + * resolve configurations of other projects. */ @UntrackedTask(because="Output is logging") -class ShowDiscrepancies extends DefaultTask +abstract class ShowDiscrepancies extends DefaultTask { + @InputFiles + @PathSensitive(PathSensitivity.NONE) + abstract ConfigurableFileCollection getDependencyReports() - @TaskAction - void show() + ShowDiscrepancies() { - // org.apache:commons-collections -> 3.2 -> [:server:modules:query, :server:api] - Map>> externals = new HashMap<>() + ConfigurableFileCollection reports = getDependencyReports() project.allprojects { Project p -> - Configuration externalConfig = p.configurations.findByName('external') - if (externalConfig != null) - { - externalConfig.resolvedConfiguration.resolvedArtifacts.each { - ResolvedArtifact dep -> - - ModuleVersionIdentifier id = dep.moduleVersion.getId() - String artifact = "${id.getGroup()}:${id.getName()}" - String version = id.getVersion() - Map> artifactMap = externals.get(artifact) - if (artifactMap == null) - { - artifactMap = new HashMap<>() - externals.put(artifact, artifactMap) - } - if (artifactMap.get(version) == null) - { - artifactMap.put(version, new ArrayList<>()) - } - List paths = artifactMap.get(version) - if (!paths.contains(p.getPath())) - paths.add(p.getPath()) - } + p.plugins.withType(LabKey) { + reports.from(p.tasks.named(LIST_EXTERNAL_DEPENDENCIES_TASK)) } } + } + + @TaskAction + void show() + { + // org.apache:commons-collections -> 3.2 -> [:server:modules:query, :server:api] + Map>> externals = new TreeMap<>() + for (File report : dependencyReports.files) + { + List lines = report.readLines(StandardCharsets.UTF_8.name()) + if (lines.isEmpty()) + continue + String projectPath = lines.get(0) + for (String coordinates : lines.subList(1, lines.size())) + { + int versionStart = coordinates.lastIndexOf(':') + String artifact = coordinates.substring(0, versionStart) + String version = coordinates.substring(versionStart + 1) + List paths = externals.computeIfAbsent(artifact, { new TreeMap<>() }) + .computeIfAbsent(version, { new ArrayList<>() }) + if (!paths.contains(projectPath)) + paths.add(projectPath) + } + } // look for maps that have more than one version and report these for (Map.Entry>> entry : externals.entrySet()) { @@ -74,7 +84,6 @@ class ShowDiscrepancies extends DefaultTask this.logger.error("\t${versionEntry.key}\t${versionEntry.value}") } } - } } } diff --git a/src/main/groovy/org/labkey/gradle/util/BuildUtils.groovy b/src/main/groovy/org/labkey/gradle/util/BuildUtils.groovy index 897e376e..bed714ab 100644 --- a/src/main/groovy/org/labkey/gradle/util/BuildUtils.groovy +++ b/src/main/groovy/org/labkey/gradle/util/BuildUtils.groovy @@ -27,6 +27,7 @@ import org.gradle.api.file.DirectoryProperty import org.gradle.api.initialization.Settings import org.gradle.api.invocation.Gradle import org.gradle.api.provider.Provider +import org.gradle.process.ExecSpec import org.labkey.gradle.plugin.extension.LabKeyExtension import org.labkey.gradle.plugin.extension.ModuleExtension import org.labkey.gradle.plugin.extension.ServerDeployExtension @@ -508,28 +509,27 @@ class BuildUtils (String) TeamCityExtension.getTeamCityProperty(project, "system.teamcity.agent.dotnet.build_id", // Unique build ID TeamCityExtension.getTeamCityProperty(project,"build.number", null)) Properties ret = new Properties() - def gitCmd = SystemUtils.IS_OS_WINDOWS ? "git.exe" : "git" if (project.hasProperty("includeVcs") && (!project.hasProperty("lkModule") || project.lkModule.getModProperties().get(VCS_URL_PROP_NAME).isEmpty())) { - def url = "${gitCmd} -C ${project.projectDir.absolutePath} config --get remote.origin.url".execute().text.trim() + def url = getGitOutput(project, "config", "--get", "remote.origin.url") Matcher matcher = GIT_URL_WITH_TOKEN.matcher(url) if (matcher.matches()) // Strip out the token if included in the URL. url = matcher.group(1) + "@" + matcher.group(3) ret.setProperty(VCS_URL_PROP_NAME, url) project.logger.info("${project.path} git url: ${url}") - def branch = "${gitCmd} -C ${project.projectDir.absolutePath} rev-parse --abbrev-ref HEAD".execute().text.trim() + def branch = getGitOutput(project, "rev-parse", "--abbrev-ref", "HEAD") project.logger.info("${project.path} git branch: ${branch}") ret.setProperty(VCS_BRANCH_PROP_NAME, branch) - def revision = "${gitCmd} -C ${project.projectDir.absolutePath} rev-parse @".execute().text.trim() + def revision = getGitOutput(project, "rev-parse", "@") project.logger.info("${project.path} git revision: ${revision}") ret.setProperty(VCS_REVISION_PROP_NAME, revision) if (shouldCheckVersionTag(project)) { String labkeyVersion = project.property("labkeyVersion") - List tagsAtRevision = "${gitCmd} -C ${project.projectDir.absolutePath} tag --points-at ${revision}".execute().text.split(/\r?\n/)*.trim().findAll { !it.isEmpty() } + List tagsAtRevision = getGitOutput(project, "tag", "--points-at", revision).split(/\r?\n/)*.trim().findAll { !it.isEmpty() } if (!tagsAtRevision.contains(labkeyVersion)) throw new GradleException("Current commit ${revision} in ${project.name} does not have a tag matching labkeyVersion '${labkeyVersion}'") } @@ -544,6 +544,23 @@ class BuildUtils return ret } + /** + * Runs a git command in the project's directory. Uses ProviderFactory.exec because starting an external process + * directly at configuration time is deprecated (and will fail in Gradle 11). + * @param project the project in whose directory to run the command + * @param args the arguments to the git command + * @return the trimmed standard output of the command + */ + private static String getGitOutput(Project project, String... args) + { + List command = [SystemUtils.IS_OS_WINDOWS ? "git.exe" : "git", "-C", project.projectDir.absolutePath] + command.addAll(args) + return project.providers.exec { ExecSpec spec -> + spec.commandLine(command) + spec.ignoreExitValue = true + }.standardOutput.asText.get().trim() + } + private static boolean shouldCheckVersionTag(Project project) { if (!shouldPublish(project)) // no check necessary if not publishing return false