You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@groovy.apache.org by pa...@apache.org on 2026/08/27 06:34:03 UTC

(groovy-release) branch master updated: update scripts to close/release artifactory repos

This is an automated email from the ASF dual-hosted git repository.

paulk-asert pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/groovy-release.git


The following commit(s) were added to refs/heads/master by this push:
     new 21f6a42  update scripts to close/release artifactory repos
21f6a42 is described below

commit 21f6a426f7a8d60686d391850b8d5b4abdb53580
Author: Paul King <pa...@asert.com.au>
AuthorDate: Thu Aug 27 16:33:47 2026 +1000

    update scripts to close/release artifactory repos
---
 buildSrc/src/main/groovy/HttpUtil.groovy  | 21 ++++++++
 buildSrc/src/main/groovy/NexusUtil.groovy | 83 +++++++++++++++++++++++++++++++
 gradle/phase1.gradle                      | 36 +++++++++++++-
 gradle/phase2.gradle                      | 35 ++++++++++++-
 4 files changed, 173 insertions(+), 2 deletions(-)

diff --git a/buildSrc/src/main/groovy/HttpUtil.groovy b/buildSrc/src/main/groovy/HttpUtil.groovy
index 471af6d..775b554 100644
--- a/buildSrc/src/main/groovy/HttpUtil.groovy
+++ b/buildSrc/src/main/groovy/HttpUtil.groovy
@@ -22,6 +22,27 @@ class HttpUtil {
                 .build()
     }
 
+    // Nexus returns XML unless we explicitly ask for JSON
+    static HttpRequest getJsonRequest(url, user, password) {
+        HttpRequest.newBuilder()
+                .uri(new URI(url))
+                .header('Authorization', 'Basic ' + "$user:$password".getBytes('iso-8859-1').encodeBase64())
+                .header('Accept', 'application/json')
+                .GET()
+                .build()
+    }
+
+    static HttpRequest postJsonRequest(url, String json, user, password) {
+        def body = HttpRequest.BodyPublishers.ofString(json)
+        HttpRequest.newBuilder()
+                .uri(new URI(url))
+                .header('Authorization', 'Basic ' + "$user:$password".getBytes('iso-8859-1').encodeBase64())
+                .header('Accept', 'application/json')
+                .header('Content-Type', 'application/json')
+                .POST(body)
+                .build()
+    }
+
     static HttpRequest headRequest(url) {
         HttpRequest.newBuilder()
                 .uri(new URI(url))
diff --git a/buildSrc/src/main/groovy/NexusUtil.groovy b/buildSrc/src/main/groovy/NexusUtil.groovy
new file mode 100644
index 0000000..c6cddb2
--- /dev/null
+++ b/buildSrc/src/main/groovy/NexusUtil.groovy
@@ -0,0 +1,83 @@
+/**
+ * Helpers for the Nexus 2 staging API used by the Apache repository at repository.apache.org.
+ * Gradle's maven-publish plugin just PUTs to the implicit staging endpoint - it has no notion
+ * of the staging repository Nexus creates behind the scenes, so we drive the lifecycle
+ * (close in phase1, release in phase2) via the staging REST API.
+ */
+class NexusUtil {
+    private NexusUtil() {}
+
+    static String stagingBase(nexusBase) {
+        "$nexusBase/service/local/staging"
+    }
+
+    /**
+     * Finds the staging repository of the given type ('open', 'closed', ...) for the given groupId.
+     * Nexus creates these implicitly, so we can't label them at deploy time - instead we match on
+     * the staging profile, since the implicit repository id is the profile name minus the dots,
+     * e.g. profile 'org.apache.groovy' gives 'orgapachegroovy-1118'. That is enough to ignore other
+     * projects releasing at the same time; only if we have two of our own releases in flight do we
+     * look at the repository contents to work out which one holds the version being released.
+     * Pass a repoId to skip the search altogether.
+     */
+    static findRepo(client, nexusBase, user, password, String type, String groupId, String version, repoId = null) {
+        def staging = stagingBase(nexusBase)
+        if (repoId) {
+            def response = HttpUtil.send(client, HttpUtil.getJsonRequest("$staging/repository/$repoId", user, password))
+            assert response.statusCode() == 200, "Staging repository $repoId not found"
+            return response.json
+        }
+        def response = HttpUtil.send(client, HttpUtil.getJsonRequest("$staging/profile_repositories", user, password))
+        assert response.statusCode() == 200
+        def matching = response.json.data.findAll { it.type == type && !it.transitioning }
+        println "${type.capitalize()} staging repositories: ${matching*.repositoryId.join(', ') ?: 'none'}"
+
+        def prefix = groupId.replace('.', '')
+        def ours = matching.findAll { it.profileName == groupId || it.repositoryId?.startsWith(prefix + '-') }
+        assert ours, "No $type staging repository found for $groupId " +
+                "(if it is no longer $type, rerun with -PstagingRepoId=the_repo_id)"
+        if (ours.size() == 1) return ours[0]
+
+        // more than one of our own releases in flight, so pick the repo actually holding this version
+        def candidates = ours.findAll { containsRelease(client, it.repositoryURI, groupId, version, user, password) }
+        assert candidates.size() == 1, "Cannot tell which $type staging repository holds groovy $version: " +
+                "${ours*.repositoryId} - rerun with -PstagingRepoId=the_repo_id"
+        candidates[0]
+    }
+
+    /** Checks whether a staging repository holds the core pom for the version being released. */
+    static boolean containsRelease(client, repoURI, String groupId, String version, user, password) {
+        def url = "$repoURI/${groupId.replace('.', '/')}/groovy/$version/groovy-${version}.pom"
+        // staging content is readable anonymously - only fall back to our credentials if it isn't,
+        // otherwise a rejected login would look just like a repository without the artifacts
+        def status = HttpUtil.send(client, HttpUtil.headRequest(url)).statusCode()
+        if (status in [401, 403]) {
+            status = HttpUtil.send(client, HttpUtil.headRequest(url, user, password)).statusCode()
+        }
+        status == 200
+    }
+
+    /**
+     * Polls until the given repository stops transitioning and has the expected type.
+     * A released repository is dropped automatically, so a repository which has vanished
+     * also counts as done. Returns false if we gave up waiting.
+     */
+    static boolean awaitTransition(client, nexusBase, user, password, repoId, String expectedType,
+                                   int delay = 15000, int numTries = 40) {
+        def staging = stagingBase(nexusBase)
+        while (numTries-- > 0) {
+            sleep delay
+            def response = HttpUtil.send(client, HttpUtil.getJsonRequest("$staging/repository/$repoId", user, password))
+            if (response.statusCode() == 404) return true
+            assert response.statusCode() == 200
+            def current = response.json
+            if (!current.transitioning) {
+                assert current.type == expectedType,
+                        "Staging repository $repoId is '$current.type' not '$expectedType' - " +
+                                "check the Activity tab at $nexusBase/#stagingRepositories"
+                return true
+            }
+        }
+        false
+    }
+}
diff --git a/gradle/phase1.gradle b/gradle/phase1.gradle
index 7a5cbf5..19f34af 100644
--- a/gradle/phase1.gradle
+++ b/gradle/phase1.gradle
@@ -14,6 +14,9 @@ buildscript {
 apply plugin: at.bxm.gradleplugins.svntools.SvnToolsPlugin
 
 ext.grgitClass = org.ajoberstar.grgit.Grgit
+ext.nexusBase = 'https://repository.apache.org'
+// set by closeStagingRepo, referenced by prepareVoteThread
+ext.stagingRepoUrl = null
 
 svntools {
     username = apacheUser
@@ -186,8 +189,38 @@ tasks.register('uploadStep2', Exec) {
 }
 uploadStep2.onlyIf{ apacheGroupId }
 
+// Gradle's maven-publish plugin just PUTs to the Nexus implicit staging endpoint - it has no notion
+// of the staging repository Nexus creates behind the scenes, so we close it via the Nexus 2 staging API.
+tasks.register('closeStagingRepo') {
+    dependsOn([assumesPropsSet, uploadStep2])
+    group = "Pre-vote phase"
+    description = "Closes the Nexus staging repository holding the uploaded Maven artifacts"
+    doLast {
+        def client = HttpUtil.newClient()
+        def staging = NexusUtil.stagingBase(nexusBase)
+        def groupId = apacheGroupId ? 'org.apache.groovy' : 'org.codehaus.groovy'
+        def repo = NexusUtil.findRepo(client, nexusBase, apacheUser, apachePassword, 'open',
+                groupId, relVersion, project.findProperty('stagingRepoId'))
+
+        if (repo.type == 'open') {
+            println "Closing staging repository $repo.repositoryId ..."
+            def body = /{"data":{"stagedRepositoryId":"$repo.repositoryId","description":"Apache Groovy $relVersion"}}/
+            def response = HttpUtil.send(client, HttpUtil.postJsonRequest("$staging/profiles/$repo.profileId/finish", body, apacheUser, apachePassword))
+            assert response.statusCode() in [200, 201], "Close request failed: ${response.statusCode()} ${response.body()}"
+            def closed = NexusUtil.awaitTransition(client, nexusBase, apacheUser, apachePassword, repo.repositoryId, 'closed')
+            assert closed, "Timed out waiting for $repo.repositoryId to close - please check manually"
+        } else {
+            println "Staging repository $repo.repositoryId is already $repo.type"
+        }
+
+        project.ext.stagingRepoUrl = repo.repositoryURI ?: "$nexusBase/content/repositories/$repo.repositoryId"
+        println "Staging repository: $stagingRepoUrl"
+    }
+}
+closeStagingRepo.onlyIf{ apacheGroupId && releaseBuild }
+
 tasks.register('cleanSvnDevWorkspace', Delete) {
-    dependsOn([assumesBranch, uploadStep2])
+    dependsOn([assumesBranch, closeStagingRepo])
     delete devWorkspaceRoot
 }
 
@@ -317,6 +350,7 @@ Tag commit id: $tagId
 The artifacts to be voted on are located as follows (r$svnVersion.maxRevisionNumber).
 Source release: https://dist.apache.org/repos/dist/dev/groovy/$relVersion/sources
 Convenience binaries: https://dist.apache.org/repos/dist/dev/groovy/$relVersion/distribution
+Temporary artifacts: ${stagingRepoUrl ?: 'N/A (staging repository not closed)'}
 
 Release artifacts are signed with a key from the following file:
 https://dist.apache.org/repos/dist/release/groovy/KEYS
diff --git a/gradle/phase2.gradle b/gradle/phase2.gradle
index 50c06b7..be0e630 100644
--- a/gradle/phase2.gradle
+++ b/gradle/phase2.gradle
@@ -67,6 +67,37 @@ tasks.register('jiraCheckPhase2') {
     }
 }
 
+// The vote has passed, so the staging repository closed in phase1 can be released - Nexus 2 calls
+// this staging action "promote". Releasing publishes the artifacts into the Apache release repo
+// from where they sync to Maven Central; the staging repo itself is dropped once released.
+// We use the bulk endpoint here (what the Release button in the Nexus UI uses) - the per-profile
+// promote DTO has no autoDropAfterRelease field and XStream rejects the whole body if we send it.
+tasks.register('releaseStagingRepo') {
+    dependsOn([assumesRelVersion, jiraCheckPhase2])
+    group = "Post-passed phase"
+    description = "Releases the closed Nexus staging repository holding the Maven artifacts"
+    doLast {
+        def client = HttpUtil.newClient()
+        def staging = NexusUtil.stagingBase(nexusBase)
+        def groupId = apacheGroupId ? 'org.apache.groovy' : 'org.codehaus.groovy'
+        def repo = NexusUtil.findRepo(client, nexusBase, apacheUser, apachePassword, 'closed',
+                groupId, relVersion, project.findProperty('stagingRepoId'))
+
+        if (repo.type == 'closed') {
+            println "Releasing staging repository $repo.repositoryId ..."
+            def body = /{"data":{"stagedRepositoryIds":["$repo.repositoryId"],"description":"Apache Groovy $relVersion","autoDropAfterRelease":true}}/
+            def response = HttpUtil.send(client, HttpUtil.postJsonRequest("$staging/bulk/promote", body, apacheUser, apachePassword))
+            assert response.statusCode() in [200, 201], "Release request failed: ${response.statusCode()} ${response.body()}"
+            def released = NexusUtil.awaitTransition(client, nexusBase, apacheUser, apachePassword, repo.repositoryId, 'released')
+            assert released, "Timed out waiting for $repo.repositoryId to be released - please check manually"
+        } else {
+            println "Staging repository $repo.repositoryId is already $repo.type"
+        }
+        println "Artifacts should appear on Maven Central within a few hours - the checkMavenStatus task confirms it"
+    }
+}
+releaseStagingRepo.onlyIf{ apacheGroupId && releaseBuild }
+
 /*
 task promoteJarsOnArtifactory(dependsOn: jiraCheckPhase2) {
     group = "Post-passed phase"
@@ -466,8 +497,10 @@ tasks.register('jiraTasks') { dependsOn([findGroovyVersions, createNextVersionIn
 
 tasks.register('sdkmanTasks') { dependsOn([jiraCheckPhase2, findGroovyVersions, makeDefaultOnSDKman]) }
 
+tasks.register('mavenTasks') { dependsOn([jiraCheckPhase2, releaseStagingRepo]) }
+
 tasks.register('proposeAnnouncementEmail') {
-    dependsOn([gitTasks, jiraTasks, sdkmanTasks, findGroovyVersions])
+    dependsOn([gitTasks, jiraTasks, sdkmanTasks, mavenTasks, findGroovyVersions])
     group = "Post-passed phase"
     description = "Generates an [ANNOUNCE] thread to be tweaked and sent to the dev@, user@ and announce@ mailing lists"
     doLast {