1+ /*
2+ * Copyright 2021 Google LLC
3+ *
4+ * Licensed under the Apache License, Version 2.0 (the "License");
5+ * you may not use this file except in compliance with the License.
6+ * You may obtain a copy of the License at
7+ *
8+ * https://www.apache.org/licenses/LICENSE-2.0
9+ *
10+ * Unless required by applicable law or agreed to in writing, software
11+ * distributed under the License is distributed on an "AS IS" BASIS,
12+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+ * See the License for the specific language governing permissions and
14+ * limitations under the License.
15+ */
16+
17+ /**
18+ * Defines a plugin which will automatically configure gradle publish.
19+ *
20+ * Test this class by setting a non-snapshot version in gradle.properties.
21+ * Nexus rejects snapshot artifacts with HTTP 400 (and little else).
22+ */
23+
24+ plugins {
25+ id ' maven-publish'
26+ }
27+
28+ import com.google.api.client.http.GenericUrl
29+ import com.google.api.client.http.HttpHeaders
30+ import com.google.api.client.http.HttpRequest
31+ import com.google.api.client.http.HttpRequestInitializer
32+ import com.google.api.client.http.javanet.NetHttpTransport
33+ import com.google.api.client.http.xml.XmlHttpContent
34+ import com.google.api.client.util.Key
35+ import com.google.api.client.xml.XmlNamespaceDictionary
36+ import com.google.api.client.xml.XmlObjectParser
37+ import java.nio.charset.StandardCharsets
38+ import java.util.concurrent.ConcurrentHashMap
39+ import org.gradle.api.GradleException
40+
41+ // ---------------- Google HTTP client data binding classes --------------------
42+ // Nexus REST API uses XML over HTTP - yey!
43+ // Leverages the data-binding in google-java-http-client to avoid handling XML.
44+
45+ /* * Defines data binding class for HTTP basic auth headers. */
46+ class AuthHeaders extends HttpHeaders {
47+ @Key (" Authorization" )
48+ public String authorization
49+
50+ AuthHeaders (String user , String password ) {
51+ // Generates the base 64 encoded version of user:pass which is specified
52+ // in headers.
53+ def encodedCredential = Base64 . encoder. encode(" ${ user} :${ password} " . bytes)
54+ this . authorization = " Basic " + new String (encodedCredential, StandardCharsets . UTF_8 )
55+ }
56+ }
57+
58+ /**
59+ * Defines data-binding to retrieve the staging profiles from sonatype. Staging repositories are
60+ * created from a staging profile.
61+ */
62+ class StagingProfiles {
63+ @Key
64+ public StagingProfilesData data
65+
66+ static class StagingProfilesData {
67+ @Key
68+ public List<StagingProfile > stagingProfile
69+
70+ static class StagingProfile {
71+ @Key
72+ public String resourceURI
73+ }
74+ }
75+
76+ def getStagingProfileUrls () {
77+ return data. stagingProfile. collect { it. resourceURI }
78+ }
79+ }
80+
81+ /* * Defines data binding class for Nexus POST request to create staging repo. */
82+ class PromoteRequestXmlContent extends XmlHttpContent {
83+ PromoteRequestXmlContent (namespaceDictionary , description ) {
84+ super (namespaceDictionary,
85+ " promoteRequest" ,
86+ new PromoteRequest (description))
87+ }
88+
89+ /* * Defines top-level payload of PromoteRequest. */
90+ static class PromoteRequest {
91+ @Key
92+ public PromoteRequestData data
93+
94+ PromoteRequest () {}
95+
96+ PromoteRequest (String description ) {
97+ data = new PromoteRequestData (description)
98+ }
99+
100+ static class PromoteRequestData {
101+ /* * Appears next to the staging repo in the Nexus UI. */
102+ @Key
103+ public String description
104+
105+ /* * Returned by Nexus. This is the newly created repository ID. */
106+ @Key
107+ public String stagedRepositoryId
108+
109+ PromoteRequestData () {}
110+
111+ PromoteRequestData (String description ) {
112+ this . description = description
113+ }
114+ }
115+ }
116+ }
117+
118+ // -------------------------- Nexus client implementation ----------------------
119+
120+ /**
121+ * Implements a client for Sonatype Nexus.
122+ *
123+ * Pass --info to gradle to see the HTTP traffic.
124+ * Logging of raw HTTP is handled by the the HTTP client (google-java-http-client) which uses JUL.
125+ *
126+ * This implementation is based on:
127+ * https://support.sonatype.com/hc/en-us/articles/213465868-Uploading-to-a-Staging-Repository-via-REST-API
128+ *
129+ * Note that there are two versions of Nexus: v2 and v3. The OSS instance (which hosts our project)
130+ * uses v2.
131+ *
132+ * The Nexus docs are here: https://help.sonatype.com/repomanager2
133+ * The REST docs are here: https://repository.sonatype.org/nexus-restlet1x-plugin/default/docs/index.html
134+ * The docs are very patchy!
135+ */
136+ class SonatypeClient {
137+
138+ private static final logger = org.slf4j.LoggerFactory . getLogger(SonatypeClient . class)
139+ private static final AUTO_STAGING_URL =
140+ " https://oss.sonatype.org/service/local/staging/deploy/maven2"
141+ private static final DEPLOY_BY_REPO_URL_STEM =
142+ " https://oss.sonatype.org/service/local/staging/deployByRepositoryId/"
143+ private static final STAGING_PROFILES_URL = new GenericUrl (
144+ " https://oss.sonatype.org/service/local/staging/profiles" )
145+ private static final NAMESPACE_DICTIONARY = new XmlNamespaceDictionary (). set(" " , " " )
146+ private static final REQUEST_FACTORY = new NetHttpTransport ()
147+ .createRequestFactory(new HttpRequestInitializer () {
148+ @Override
149+ void initialize (HttpRequest request ) throws IOException {
150+ request. setParser(new XmlObjectParser (NAMESPACE_DICTIONARY ))
151+ }
152+ })
153+ /**
154+ * Global storage of URLs for a rootProject.
155+ */
156+ private static final STAGING_URL_CACHE = new ConcurrentHashMap ();
157+ private final project
158+ private final authHeaders
159+
160+ SonatypeClient (project ) {
161+ this . project = project
162+ this . authHeaders = new AuthHeaders (
163+ project. properties. get(" sonatypeUser" ),
164+ project. properties. get(" sonatypePassword" ))
165+ }
166+
167+ /**
168+ * Provides the Sonatype repository URL to use for deploying releases.
169+ *
170+ * Attempts to create a named staging repository to avoid using Nexus auto-staging if
171+ * possible. If not possible to create a named staging repository, falls back to auto-staging.
172+ *
173+ * This is motivated by the observation that auto-staging's heuristic for which artifacts
174+ * belong in the same repository is often confused by Gradle's publish mechanism, which
175+ * appears to create a new TCP connection for each artifact. This also appears to be flaky
176+ * based on the sonatype server load.
177+ */
178+ def discoverSonatypeRepositoryUrl () {
179+ try {
180+ // Ensures that we don't create spurious repositories if we're not running a release.
181+ if (project. properties. containsKey(" release" )) {
182+ // Gradle may reuse the demon (hence its classloader) so this uses a map to store
183+ // the staging repo for each build.
184+ return STAGING_URL_CACHE . computeIfAbsent(
185+ project. rootProject, { project -> this . createStagingRepository() })
186+ } else {
187+ logger. debug(" Release not enabled, defaulting to auto-staging" )
188+ return AUTO_STAGING_URL
189+ }
190+ } catch (Exception ex) {
191+ // Handles issues by falling back to auto-staging.
192+ logger. warn(" Failed to create new staging repo. Attempting to deploy with " +
193+ " auto-staging. The release *may* complete as planned. You can proceed by" +
194+ " making sure that all release artifacts are present in the auto-staging " +
195+ " repository. Please investigate why staging failed to create." , ex)
196+ return AUTO_STAGING_URL
197+ }
198+ }
199+
200+ /* * Creates a staging repository on Sonatype. */
201+ private def createStagingRepository () {
202+ logger. info(" Creating staging repository" )
203+ def stagingProfileUrls = getStagingProfileUrls()
204+ if (stagingProfileUrls. size() == 0 ) {
205+ throw new GradleException (" No staging profiles found" )
206+ } else if (stagingProfileUrls. size() > 1 ) {
207+ logger. warn(" Found multiple staging profiles, using the first one" )
208+ }
209+ def stagingStartUrl = new GenericUrl (stagingProfileUrls[0 ] + " /start" )
210+ def content = new PromoteRequestXmlContent (
211+ NAMESPACE_DICTIONARY , generateStagingRepoDescription())
212+ def response = REQUEST_FACTORY
213+ .buildPostRequest(stagingStartUrl, content)
214+ .setHeaders(authHeaders)
215+ .execute()
216+ // Checks the response and returns the repository URL, throwing if we
217+ // didn't get a valid response.
218+ if (response. getStatusCode() == 201 ) { // HTTP 201 = Created
219+ def parsed = response. parseAs(PromoteRequestXmlContent.PromoteRequest . class)
220+ logger. info(" Staging repository created with ID ${ parsed.data.stagedRepositoryId} " )
221+ return DEPLOY_BY_REPO_URL_STEM + parsed. data. stagedRepositoryId
222+ } else {
223+ throw new GradleException (" Nexus failed to create staging repo: " +
224+ " ${ response.getStatusCode()} : ${ response.getStatusMessage()} " )
225+ }
226+ }
227+
228+ /* * Retrieves the staging profiles from Nexus. */
229+ private def getStagingProfileUrls () {
230+ logger. debug(" Retrieving staging profiles" )
231+ def response = REQUEST_FACTORY
232+ .buildGetRequest(STAGING_PROFILES_URL )
233+ .setHeaders(authHeaders)
234+ .execute()
235+ if (response. getStatusCode() == 200 ) {
236+ def parsed = response. parseAs(StagingProfiles . class)
237+ return parsed. getStagingProfileUrls()
238+ } else {
239+ throw new GradleException (" Failed to retrieve staging profiles: HTTP " +
240+ " ${ response.getStatusCode()} : ${ response.getStatusMessage()} " )
241+ }
242+ }
243+
244+ /* * Generates a pseudo-unique and human readable description for a staging repo. */
245+ private def generateStagingRepoDescription () {
246+ return " Release of Google Ads API v${ project.version} " +
247+ " on ${ InetAddress.getLocalHost().getCanonicalHostName()} " +
248+ " ${ new Date()} "
249+ }
250+ }
251+
252+ // ----------------------- Gradle project configuration ------------------------
253+
254+ // Configures gradle's native publishing now that debacle is over.
255+ publishing {
256+ publications {
257+ maven(MavenPublication ) {
258+ from(components. java)
259+ }
260+ }
261+ repositories {
262+ maven {
263+ url new SonatypeClient (project). discoverSonatypeRepositoryUrl()
264+ name = " sonatype"
265+ credentials {
266+ // Avoids storing Sonatype credentials in plain-text. Specify these on
267+ // the command line: -PsonatypeUser=foo
268+ username project. properties. get(" sonatypeUser" )
269+ password project. properties. get(" sonatypePassword" )
270+ }
271+ }
272+ }
273+ }
0 commit comments