Skip to content
Merged
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,15 @@ on how to do that, including how to develop and test locally and the versioning

## Release Notes

### TBD
*Released*: TBD
### 7.2.0
*Released*: 11 December 2025
(Earliest compatible LabKey version: 25.10)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

25.12?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should still be compatible with 25.10 since the presence of the versioning plugin version shouldn't cause problems; we simply won't be using it.

- Remove `ContainerListener` from module template
- Java 25: Remove obsolete JVM flags from tasks (`-Xdebug`, `-Xnoagent`, `-Xrunjdwp`, `-Djava.compiler`)
- Java 25: Update gradle wrapper to 9.2.1
- Remove logging of XMLBeans version
- Add `PurgeNpmVersions` task
- Remove use of `versioning` plugin in favor of capturing output from a few dedicated `git` commands

### 7.1.0
*Released*: 17 October 2025
Expand Down
2 changes: 1 addition & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ dependencies {
}

group = 'org.labkey.build'
project.version = "7.2.0-SNAPSHOT"
project.version = "7.3.0-SNAPSHOT"

gradlePlugin {
plugins {
Expand Down
8 changes: 0 additions & 8 deletions src/main/groovy/org/labkey/gradle/plugin/LabKey.groovy
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,6 @@ class LabKey implements Plugin<Project>
@Override
void apply(Project project)
{
if (project.hasProperty('includeVcs'))
{
if (project.hasProperty('nemerosaVersioningPluginVersion'))
project.apply plugin: 'net.nemerosa.versioning'
else
project.apply plugin: 'org.labkey.versioning'
}

project.group = LabKeyExtension.LABKEY_GROUP
project.version = BuildUtils.getVersionNumber(project)
project.subprojects { Project subproject ->
Expand Down
1 change: 0 additions & 1 deletion src/main/groovy/org/labkey/gradle/plugin/XmlBeans.groovy
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,6 @@ class XmlBeans implements Plugin<Project>
xmlbeans "org.apache.xmlbeans:xmlbeans:${project.xmlbeansVersion}"
}

project.logger.quiet("XMLBeans version: ${project.xmlbeansVersion}")
String schemasProjectPath = BuildUtils.getSchemasProjectPath(project.gradle)
if (!project.path.equals(schemasProjectPath))
{
Expand Down
23 changes: 3 additions & 20 deletions src/main/groovy/org/labkey/gradle/task/PurgeArtifacts.groovy
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import org.gradle.api.provider.Property
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.Optional
import org.gradle.api.tasks.TaskAction
import org.labkey.gradle.util.TaskUtils

import java.nio.file.Paths

Expand Down Expand Up @@ -57,7 +58,7 @@ class PurgeArtifacts extends DefaultTask
String purgeModulesFileName = purgeListFile.get()
if (StringUtils.isEmpty(purgeModulesFileName))
throw new GradleException("Use -P${PURGE_LIST_FILE_PROPERTY}=<moduleNames.txt> to provide a list of modules to work with.")
List<String> moduleNames = readInputFile(purgeListFile.get(), "modules")
List<String> moduleNames = TaskUtils.readInputFile(purgeListFile.get(), "modules", logger)
if (moduleNames.isEmpty())
throw new GradleException("No module names found in file ${purgeListFile.get()}")
if (!StringUtils.isEmpty(version))
Expand All @@ -70,7 +71,7 @@ class PurgeArtifacts extends DefaultTask
String purgeVersionsFileName = purgeVersions.get()
if (StringUtils.isEmpty(purgeVersionsFileName))
throw new GradleException("Either -P${VERSION_PROPERTY}=<versionToPurge> or -P${VERSIONS_FILE_PROPERTY}=<versionsFile.txt> must be provided")
List<String> versions = readInputFile(purgeVersionsFileName, "versions")
List<String> versions = TaskUtils.readInputFile(purgeVersionsFileName, "versions", logger)
if (versions.isEmpty())
throw new GradleException("No versions found for file ${purgeVersionsFileName}.")
if (versions.size() > 1) {
Expand All @@ -95,24 +96,6 @@ class PurgeArtifacts extends DefaultTask

}

List<String> readInputFile(String fileName, String type)
{
if (!StringUtils.isEmpty(fileName)) {
File listing = Paths.get(fileName).toFile();
if (listing.exists()) {
logger.quiet("Reading ${type} purge list from file ${listing.getAbsolutePath()}.")
try (BufferedReader reader = new BufferedReader(new InputStreamReader(new FileInputStream(listing)))) {
List<String> lines = IOUtils.readLines(reader).stream().filter(line -> !line.startsWith("#")).toList()
logger.quiet("... found ${lines.size()} uncommented lines for purging")
return lines
}
} else {
throw new GradleException("No such file or directory: ${fileName}")
}
} else {
throw new GradleException("No file name provided for ${type} input")
}
}

Map<String, Object> purgeModuleVersions(String moduleName, List<String> versions)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import org.labkey.gradle.plugin.NpmRun

import java.util.stream.Collectors

abstract class PurgeNpmAlphaVersions extends DefaultTask
abstract class PurgeNpmAlphaVersions extends PurgeNpmVersions
{
private static final String REPOSITORY_NAME = 'libs-client-local'
public static final String ALPHA_PREFIX_PROPERTY = 'alphaPrefix'
Expand All @@ -34,14 +34,6 @@ abstract class PurgeNpmAlphaVersions extends DefaultTask

@Input
final abstract Property<String> alphaPrefixProp = project.objects.property(String).convention((project.hasProperty(ALPHA_PREFIX_PROPERTY) ? (String) project.property(ALPHA_PREFIX_PROPERTY) : null))
@Input
final abstract Property<Boolean> isDryRun = project.objects.property(Boolean).convention(project.hasProperty(DRY_RUN_PROPERTY))
@Input
final abstract Property<String> artifactoryUrl = project.objects.property(String).convention((String) project.property('artifactory_contextUrl'))
@Input
final abstract Property<String> artifactoryUser = project.objects.property(String).convention((String) project.property('artifactory_user'))
@Input
final abstract Property<String> artifactoryPassword = project.objects.property(String).convention((String) project.property('artifactory_password'))

@TaskAction
void purgeVersions()
Expand Down
132 changes: 132 additions & 0 deletions src/main/groovy/org/labkey/gradle/task/PurgeNpmVersions.groovy
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
package org.labkey.gradle.task


import org.apache.commons.lang3.StringUtils
import org.apache.hc.client5.http.classic.methods.HttpDelete
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient
import org.apache.hc.client5.http.impl.classic.CloseableHttpResponse
import org.apache.hc.client5.http.impl.classic.HttpClients
import org.apache.hc.core5.http.HttpStatus
import org.gradle.api.DefaultTask
import org.gradle.api.GradleException
import org.gradle.api.provider.Property
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.TaskAction
import org.labkey.gradle.util.TaskUtils

abstract class PurgeNpmVersions extends DefaultTask
{
private static final String REPOSITORY_NAME = 'libs-client-local'
public static final String DRY_RUN_PROPERTY = 'dryRun'
private static final String PACKAGE_NAME_PROP = "packageName"
private static final String VERSION_LIST_PROP = "versionList"

@Input
final abstract Property<String> packageName = project.objects.property(String).convention((project.hasProperty(PACKAGE_NAME_PROP) ? (String) project.property(PACKAGE_NAME_PROP) : null))

@Input
final abstract Property<String> versionList = project.objects.property(String).convention((project.hasProperty(VERSION_LIST_PROP) ? (String) project.property(VERSION_LIST_PROP) : null))

@Input
final abstract Property<Boolean> isDryRun = project.objects.property(Boolean).convention(project.hasProperty(DRY_RUN_PROPERTY))
@Input
final abstract Property<String> artifactoryUrl = project.objects.property(String).convention((String) project.property('artifactory_contextUrl'))
@Input
final abstract Property<String> artifactoryUser = project.objects.property(String).convention((String) project.property('artifactory_user'))
@Input
final abstract Property<String> artifactoryPassword = project.objects.property(String).convention((String) project.property('artifactory_password'))

@TaskAction
void purgeVersions()
{
if (!packageName.isPresent() || StringUtils.isEmpty(packageName.get().trim()))
throw new GradleException("No value provided for packageName.")
String packageName = "@labkey/" + packageName.get()
String[] undeletedVersions = []

logger.quiet("Considering ${packageName}...")
List<String> versions = readPurgeVersions()
if (versions.isEmpty())
logger.quiet("No versions provided.")
else {
logger.quiet("Found ${versions.size()} versions in package ${packageName}")
versions.forEach(version -> {
if (isDryRun.get())
logger.quiet("Removing version ${version} of package ${packageName} -- Skipped for dry run")
else {
logger.quiet("Removing version ${version} of package ${packageName}")
if (!makeDeleteRequest(packageName, version)) {
undeletedVersions += "${packageName}: ${version}"
}
}
})
}

if (undeletedVersions.size() > 0)
throw new GradleException("The following versions were not deleted.\n${undeletedVersions}\nCheck the log for more information.")
}

List<String> readPurgeVersions()
{
if (versionList.isPresent() && !StringUtils.isEmpty(versionList.get().trim()))
return TaskUtils.readInputFile(versionList.get(), "versions", logger)
else
throw new GradleException("No " + VERSION_LIST_PROP + " or " + VERSION_LIST_PROP + " property provided.");
}


/**
* This uses the Artifactory REST Api to request a deletion of a particular package and version. There does
* not appear to be a way to request deletion of multiple versions at once. Also, though it might seem natural
* to use "npm unpublish" for this deletion, this does not work with artifactory, possibly due to this long-standing
* issue: https://github.com/npm/npm-registry-client/issues/41
* The command appears to work, returning a 200 status code when you use --verbose logging, but the artifact doesn't
* go anywhere.
*
* Another possibility here would be to use the same action as is used in the Web UI. There, Artifactory sends
* a POST request to:
* Request URL: https://artifactory.labkey.com/artifactory/ui/artifactactions/delete
* with parameters
* repoKey: libs-client-local
* path: "@labkey/components/-/@labkey/components-2.14.2-fb-update-react-select.1.tgz"
* The REST API seems a better approach, though.
* @param packageName the package whose version is to be deleted, including the scope (e.g., @labkey/components)
* @param version the version of the package to delete (e.g., 2.14.2-fb-update-react-select.1)
* @return true if deletion was successful, false otherwise
* @throws GradleException if the delete request throws an exception
*/
protected boolean makeDeleteRequest(String packageName, String version)
{
CloseableHttpClient httpClient = HttpClients.createDefault()
String endpoint = artifactoryUrl.get()
boolean success = true
if (!endpoint.endsWith("/"))
endpoint += "/"

// The coordinates of the packages look like this: "@labkey/components/-/@labkey/components-2.14.2-fb-update-react-select.1.tgz"
endpoint += REPOSITORY_NAME + "/" + packageName + "/-/" + packageName + "-" + version + ".tgz"
logger.debug("Making delete request for package ${packageName} and version ${version} via endpoint ${endpoint}")
try
{
HttpDelete httpDelete = new HttpDelete(endpoint)
// N.B. Using Authorization Bearer with an API token does not currently work
httpDelete.setHeader("Authorization", "Basic " + Base64.getEncoder().encodeToString("${artifactoryUser.get()}:${artifactoryPassword.get()}".getBytes()))
CloseableHttpResponse response = httpClient.execute(httpDelete)
int statusCode = response.getCode()
if (statusCode != HttpStatus.SC_OK && statusCode != HttpStatus.SC_NO_CONTENT) {
logger.error("Unable to delete using ${endpoint}: ${statusCode} ${response.getReasonPhrase()}")
success = false
}
response.close()
return success
}
catch (Exception e)
{
throw new GradleException("Problem executing delete request with url ${endpoint}", e)
}
finally
{
httpClient.close()
}
}
}
56 changes: 22 additions & 34 deletions src/main/groovy/org/labkey/gradle/util/BuildUtils.groovy
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ package org.labkey.gradle.util
import org.ajoberstar.grgit.Grgit
import org.ajoberstar.grgit.Remote
import org.apache.commons.lang3.StringUtils
import org.apache.commons.lang3.SystemUtils
import org.gradle.api.GradleException
import org.gradle.api.Project
import org.gradle.api.UnknownDomainObjectException
Expand Down Expand Up @@ -52,6 +53,7 @@ class BuildUtils
public static final String PLATFORM_MODULES_DIR = "server/modules/platform"
public static final String COMMON_ASSAYS_MODULES_DIR = "server/modules/commonAssays"
public static final String CUSTOM_MODULES_DIR = "server/modules/customModules"
private static final Pattern GIT_URL_WITH_TOKEN = Pattern.compile("(https://[^:]+):([^@]+)@(.*)");

public static final List<String> EHR_MODULE_NAMES = [
"EHR_ComplianceDB",
Expand Down Expand Up @@ -494,49 +496,35 @@ 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()
if (project.plugins.hasPlugin("org.labkey.versioning"))
def gitCmd = SystemUtils.IS_OS_WINDOWS ? "git.exe" : "git"
if (project.hasProperty("includeVcs") && (!project.hasProperty("lkModule") || project.lkModule.getModProperties().get("VcsURL").isEmpty()))
{
Project vcsProject = project
while (vcsProject.versioning.info.url == "No VCS" && vcsProject != project.rootProject)
{
vcsProject = vcsProject.parent
}
vcsProject.println("${project.path} versioning info ${ vcsProject.versioning.info}")
ret.setProperty("VcsURL", vcsProject.versioning.info.url)
if (vcsProject.versioning.info.branch != null)
ret.setProperty("VcsBranch", vcsProject.versioning.info.branch)
if (vcsProject.versioning.info.tag != null)
ret.setProperty("VcsTag", vcsProject.versioning.info.tag)
ret.setProperty("VcsRevision", vcsProject.versioning.info.commit)
ret.setProperty("BuildNumber", buildNumber != null ? buildNumber : vcsProject.versioning.info.build)
}
else if (project.plugins.hasPlugin("net.nemerosa.versioning"))
{
// In our fork of the plugin (above), we added the url property to the VersioningInfo object
Project vcsProject = project
String url = getGitUrl(vcsProject)
while (url == null && vcsProject != project.rootProject)
{
vcsProject = vcsProject.parent
url = getGitUr(vcsProject)
}
vcsProject.println("${project.path} versioning info ${ vcsProject.versioning.info}")
def url = "${gitCmd} -C ${project.projectDir.absolutePath} config --get remote.origin.url".execute().text.trim()
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("VcsURL", url)
if (vcsProject.versioning.info.branch != null)
ret.setProperty("VcsBranch", vcsProject.versioning.info.branch)
if (vcsProject.versioning.info.tag != null)
ret.setProperty("VcsTag", vcsProject.versioning.info.tag)
ret.setProperty("VcsRevision", vcsProject.versioning.info.commit)
ret.setProperty("BuildNumber", buildNumber != null ? buildNumber : vcsProject.versioning.info.build)
}
project.logger.info("${project.path} git url: ${url}")
def branch = "${gitCmd} -C ${project.projectDir.absolutePath} rev-parse --abbrev-ref HEAD".execute().text.trim()
project.logger.info("${project.path} git branch: ${branch}")
ret.setProperty("VcsBranch", branch)
def revision = "${gitCmd} -C ${project.projectDir.absolutePath} rev-parse @".execute().text.trim()
project.logger.info("${project.path} git revision: ${revision}")
ret.setProperty("VcsRevision", revision)
def tag = "${gitCmd} -C ${project.projectDir.absolutePath} describe --tags --exact-match 2> /dev/null".execute().text.trim()
project.logger.info("${project.path} git tag: ${revision}")
if (!tag.isEmpty() && !tag.equals(revision))
ret.setProperty("VcsTag", tag)
else
ret.setProperty("VcsTag", "")}
else
{
ret.setProperty("VcsBranch", "Unknown")
ret.setProperty("VcsTag", "Unknown")
ret.setProperty("VcsURL", "Unknown")
ret.setProperty("VcsRevision", "Unknown")
ret.setProperty("BuildNumber", buildNumber != null ? buildNumber : "Unknown")
}
ret.setProperty("BuildNumber", buildNumber != null ? buildNumber : "Unknown")
return ret
}

Expand Down
24 changes: 24 additions & 0 deletions src/main/groovy/org/labkey/gradle/util/TaskUtils.groovy
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,16 @@
*/
package org.labkey.gradle.util

import org.apache.commons.io.IOUtils
import org.apache.commons.lang3.StringUtils
import org.gradle.api.GradleException
import org.gradle.api.Project
import org.gradle.api.Task
import org.gradle.api.UnknownTaskException
import org.gradle.api.logging.Logger
import org.gradle.api.tasks.TaskProvider

import java.nio.file.Paths
import java.util.function.Consumer

class TaskUtils
Expand Down Expand Up @@ -54,4 +59,23 @@ class TaskUtils
return Optional.empty()
}
}

static List<String> readInputFile(String fileName, String type, Logger logger)
{
if (!StringUtils.isEmpty(fileName)) {
File listing = Paths.get(fileName).toFile();
if (listing.exists()) {
logger.quiet("Reading ${type} list from file ${listing.getAbsolutePath()}.")
try (BufferedReader reader = new BufferedReader(new InputStreamReader(new FileInputStream(listing)))) {
List<String> lines = IOUtils.readLines(reader).stream().filter(line -> !line.startsWith("#")).toList()
logger.quiet("... found ${lines.size()} uncommented lines")
return lines
}
} else {
throw new GradleException("No such file or directory: ${fileName}")
}
} else {
throw new GradleException("No file name provided for ${type} input")
}
}
}