diff --git a/README.md b/README.md index b36960f7..78c3b1d2 100644 --- a/README.md +++ b/README.md @@ -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) - 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 diff --git a/build.gradle b/build.gradle index 804be6f8..18a0e731 100644 --- a/build.gradle +++ b/build.gradle @@ -43,7 +43,7 @@ dependencies { } group = 'org.labkey.build' -project.version = "7.2.0-SNAPSHOT" +project.version = "7.3.0-SNAPSHOT" gradlePlugin { plugins { diff --git a/src/main/groovy/org/labkey/gradle/plugin/LabKey.groovy b/src/main/groovy/org/labkey/gradle/plugin/LabKey.groovy index 356d179c..e041610e 100644 --- a/src/main/groovy/org/labkey/gradle/plugin/LabKey.groovy +++ b/src/main/groovy/org/labkey/gradle/plugin/LabKey.groovy @@ -34,14 +34,6 @@ class LabKey implements Plugin @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 -> diff --git a/src/main/groovy/org/labkey/gradle/plugin/XmlBeans.groovy b/src/main/groovy/org/labkey/gradle/plugin/XmlBeans.groovy index cee267c9..8e34654e 100644 --- a/src/main/groovy/org/labkey/gradle/plugin/XmlBeans.groovy +++ b/src/main/groovy/org/labkey/gradle/plugin/XmlBeans.groovy @@ -55,7 +55,6 @@ class XmlBeans implements Plugin 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)) { diff --git a/src/main/groovy/org/labkey/gradle/task/PurgeArtifacts.groovy b/src/main/groovy/org/labkey/gradle/task/PurgeArtifacts.groovy index 3f33cc88..2039d141 100644 --- a/src/main/groovy/org/labkey/gradle/task/PurgeArtifacts.groovy +++ b/src/main/groovy/org/labkey/gradle/task/PurgeArtifacts.groovy @@ -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 @@ -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}= to provide a list of modules to work with.") - List moduleNames = readInputFile(purgeListFile.get(), "modules") + List 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)) @@ -70,7 +71,7 @@ class PurgeArtifacts extends DefaultTask String purgeVersionsFileName = purgeVersions.get() if (StringUtils.isEmpty(purgeVersionsFileName)) throw new GradleException("Either -P${VERSION_PROPERTY}= or -P${VERSIONS_FILE_PROPERTY}= must be provided") - List versions = readInputFile(purgeVersionsFileName, "versions") + List versions = TaskUtils.readInputFile(purgeVersionsFileName, "versions", logger) if (versions.isEmpty()) throw new GradleException("No versions found for file ${purgeVersionsFileName}.") if (versions.size() > 1) { @@ -95,24 +96,6 @@ class PurgeArtifacts extends DefaultTask } - List 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 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 purgeModuleVersions(String moduleName, List versions) { diff --git a/src/main/groovy/org/labkey/gradle/task/PurgeNpmAlphaVersions.groovy b/src/main/groovy/org/labkey/gradle/task/PurgeNpmAlphaVersions.groovy index c73db094..10ca1ced 100644 --- a/src/main/groovy/org/labkey/gradle/task/PurgeNpmAlphaVersions.groovy +++ b/src/main/groovy/org/labkey/gradle/task/PurgeNpmAlphaVersions.groovy @@ -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' @@ -34,14 +34,6 @@ abstract class PurgeNpmAlphaVersions extends DefaultTask @Input final abstract Property alphaPrefixProp = project.objects.property(String).convention((project.hasProperty(ALPHA_PREFIX_PROPERTY) ? (String) project.property(ALPHA_PREFIX_PROPERTY) : null)) - @Input - final abstract Property isDryRun = project.objects.property(Boolean).convention(project.hasProperty(DRY_RUN_PROPERTY)) - @Input - final abstract Property artifactoryUrl = project.objects.property(String).convention((String) project.property('artifactory_contextUrl')) - @Input - final abstract Property artifactoryUser = project.objects.property(String).convention((String) project.property('artifactory_user')) - @Input - final abstract Property artifactoryPassword = project.objects.property(String).convention((String) project.property('artifactory_password')) @TaskAction void purgeVersions() diff --git a/src/main/groovy/org/labkey/gradle/task/PurgeNpmVersions.groovy b/src/main/groovy/org/labkey/gradle/task/PurgeNpmVersions.groovy new file mode 100644 index 00000000..649a55e4 --- /dev/null +++ b/src/main/groovy/org/labkey/gradle/task/PurgeNpmVersions.groovy @@ -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 packageName = project.objects.property(String).convention((project.hasProperty(PACKAGE_NAME_PROP) ? (String) project.property(PACKAGE_NAME_PROP) : null)) + + @Input + final abstract Property versionList = project.objects.property(String).convention((project.hasProperty(VERSION_LIST_PROP) ? (String) project.property(VERSION_LIST_PROP) : null)) + + @Input + final abstract Property isDryRun = project.objects.property(Boolean).convention(project.hasProperty(DRY_RUN_PROPERTY)) + @Input + final abstract Property artifactoryUrl = project.objects.property(String).convention((String) project.property('artifactory_contextUrl')) + @Input + final abstract Property artifactoryUser = project.objects.property(String).convention((String) project.property('artifactory_user')) + @Input + final abstract Property 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 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 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() + } + } +} diff --git a/src/main/groovy/org/labkey/gradle/util/BuildUtils.groovy b/src/main/groovy/org/labkey/gradle/util/BuildUtils.groovy index 9aa6a709..a0de0760 100644 --- a/src/main/groovy/org/labkey/gradle/util/BuildUtils.groovy +++ b/src/main/groovy/org/labkey/gradle/util/BuildUtils.groovy @@ -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 @@ -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 EHR_MODULE_NAMES = [ "EHR_ComplianceDB", @@ -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 } diff --git a/src/main/groovy/org/labkey/gradle/util/TaskUtils.groovy b/src/main/groovy/org/labkey/gradle/util/TaskUtils.groovy index 656a0f16..9513d213 100644 --- a/src/main/groovy/org/labkey/gradle/util/TaskUtils.groovy +++ b/src/main/groovy/org/labkey/gradle/util/TaskUtils.groovy @@ -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 @@ -54,4 +59,23 @@ class TaskUtils return Optional.empty() } } + + static List 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 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") + } + } }