Stop using Bintray to publish to Maven Central

This commit reworks the CI pipeline to remove the use of Bintray for
publishing to Maven Central. In its place it adds a new
publishToCentral command to the release scripts. This command can be
used to publish a directory tree of artifacts to the Maven Central
gateway hosted by Sonatype.

Publishing consists of 4 steps:

1. Create the staging repository
2. Deploy artifacts to the repository
3. Close the repository
4. Release the repository

The command requires 3 arguments:

1. The type of release being performed
2. Location of a build info JSON file that describes the release
   that is to be deployed
3. Root of a directory structure, in Maven repository layout, that
   contains the artifacts to be deployed

Closes gh-25107
pull/25353/head
Andy Wilkinson 4 years ago
parent 29d46c86c9
commit 98ee724ec6

@ -19,6 +19,11 @@
<spring-javaformat.version>0.0.26</spring-javaformat.version>
</properties>
<dependencies>
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcpg-jdk15to18</artifactId>
<version>1.68</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>

@ -17,14 +17,10 @@
package io.spring.concourse.releasescripts.artifactory;
import java.net.URI;
import java.time.Duration;
import java.util.Set;
import io.spring.concourse.releasescripts.ReleaseInfo;
import io.spring.concourse.releasescripts.artifactory.payload.BuildInfoResponse;
import io.spring.concourse.releasescripts.artifactory.payload.DistributionRequest;
import io.spring.concourse.releasescripts.artifactory.payload.PromotionRequest;
import io.spring.concourse.releasescripts.bintray.BintrayService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@ -53,17 +49,11 @@ public class ArtifactoryService {
private static final String BUILD_INFO_URL = ARTIFACTORY_URL + "/api/build/";
private static final String DISTRIBUTION_URL = ARTIFACTORY_URL + "/api/build/distribute/";
private static final String STAGING_REPO = "libs-staging-local";
private final RestTemplate restTemplate;
private final BintrayService bintrayService;
public ArtifactoryService(RestTemplateBuilder builder, ArtifactoryProperties artifactoryProperties,
BintrayService bintrayService) {
this.bintrayService = bintrayService;
public ArtifactoryService(RestTemplateBuilder builder, ArtifactoryProperties artifactoryProperties) {
String username = artifactoryProperties.getUsername();
String password = artifactoryProperties.getPassword();
if (StringUtils.hasLength(username)) {
@ -116,37 +106,6 @@ public class ArtifactoryService {
}
}
/**
* Deploy builds from Artifactory to Bintray.
* @param sourceRepo the source repo in Artifactory.
* @param releaseInfo the resease info
* @param artifactDigests the artifact digests
*/
public void distribute(String sourceRepo, ReleaseInfo releaseInfo, Set<String> artifactDigests) {
logger.debug("Attempting distribute via Artifactory");
if (!this.bintrayService.isDistributionStarted(releaseInfo)) {
startDistribute(sourceRepo, releaseInfo);
}
if (!this.bintrayService.isDistributionComplete(releaseInfo, artifactDigests, Duration.ofMinutes(60))) {
throw new DistributionTimeoutException("Distribution timed out.");
}
}
private void startDistribute(String sourceRepo, ReleaseInfo releaseInfo) {
DistributionRequest request = new DistributionRequest(new String[] { sourceRepo });
RequestEntity<DistributionRequest> requestEntity = RequestEntity
.post(URI.create(DISTRIBUTION_URL + releaseInfo.getBuildName() + "/" + releaseInfo.getBuildNumber()))
.contentType(MediaType.APPLICATION_JSON).body(request);
try {
this.restTemplate.exchange(requestEntity, Object.class);
logger.debug("Distribute call completed");
}
catch (HttpClientErrorException ex) {
logger.info("Failed to distribute.");
throw ex;
}
}
private PromotionRequest getPromotionRequest(String targetRepo) {
return new PromotionRequest("staged", STAGING_REPO, targetRepo);
}

@ -1,69 +0,0 @@
/*
* Copyright 2012-2019 the original author or authors.
*
* 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
*
* https://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 io.spring.concourse.releasescripts.bintray;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* {@link ConfigurationProperties @ConfigurationProperties} for the Bintray API.
*
* @author Madhura Bhave
*/
@ConfigurationProperties(prefix = "bintray")
public class BintrayProperties {
private String username;
private String apiKey;
private String repo;
private String subject;
public String getUsername() {
return this.username;
}
public void setUsername(String username) {
this.username = username;
}
public String getApiKey() {
return this.apiKey;
}
public void setApiKey(String apiKey) {
this.apiKey = apiKey;
}
public String getRepo() {
return this.repo;
}
public void setRepo(String repo) {
this.repo = repo;
}
public String getSubject() {
return this.subject;
}
public void setSubject(String subject) {
this.subject = subject;
}
}

@ -1,192 +0,0 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* 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
*
* https://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 io.spring.concourse.releasescripts.bintray;
import java.net.URI;
import java.time.Duration;
import java.util.HashSet;
import java.util.Set;
import io.spring.concourse.releasescripts.ReleaseInfo;
import io.spring.concourse.releasescripts.sonatype.SonatypeProperties;
import io.spring.concourse.releasescripts.sonatype.SonatypeService;
import org.awaitility.core.ConditionTimeoutException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.RequestEntity;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.RestTemplate;
import static org.awaitility.Awaitility.waitAtMost;
/**
* Central class for interacting with Bintray's REST API.
*
* @author Madhura Bhave
*/
@Component
public class BintrayService {
private static final Logger logger = LoggerFactory.getLogger(BintrayService.class);
private static final String BINTRAY_URL = "https://api.bintray.com/";
private static final String GRADLE_PLUGIN_REQUEST = "[ { \"name\": \"gradle-plugin\", \"values\": [\"org.springframework.boot:org.springframework.boot:spring-boot-gradle-plugin\"] } ]";
private final RestTemplate restTemplate;
private final BintrayProperties bintrayProperties;
private final SonatypeProperties sonatypeProperties;
private final SonatypeService sonatypeService;
public BintrayService(RestTemplateBuilder builder, BintrayProperties bintrayProperties,
SonatypeProperties sonatypeProperties, SonatypeService sonatypeService) {
this.bintrayProperties = bintrayProperties;
this.sonatypeProperties = sonatypeProperties;
this.sonatypeService = sonatypeService;
String username = bintrayProperties.getUsername();
String apiKey = bintrayProperties.getApiKey();
if (StringUtils.hasLength(username)) {
builder = builder.basicAuthentication(username, apiKey);
}
this.restTemplate = builder.build();
}
public boolean isDistributionStarted(ReleaseInfo releaseInfo) {
logger.debug("Checking if distribution is started");
RequestEntity<Void> request = getPackageFilesRequest(releaseInfo, 1);
try {
logger.debug("Checking Bintray");
this.restTemplate.exchange(request, PackageFile[].class).getBody();
return true;
}
catch (HttpClientErrorException ex) {
if (ex.getStatusCode() != HttpStatus.NOT_FOUND) {
throw ex;
}
return false;
}
}
public boolean isDistributionComplete(ReleaseInfo releaseInfo, Set<String> requiredDigests, Duration timeout) {
return isDistributionComplete(releaseInfo, requiredDigests, timeout, Duration.ofSeconds(20));
}
public boolean isDistributionComplete(ReleaseInfo releaseInfo, Set<String> requiredDigests, Duration timeout,
Duration pollInterval) {
logger.debug("Checking if distribution is complete");
RequestEntity<Void> request = getPackageFilesRequest(releaseInfo, 1);
try {
waitAtMost(timeout).with().pollDelay(Duration.ZERO).pollInterval(pollInterval).until(() -> {
logger.debug("Checking Bintray");
try {
PackageFile[] published = this.restTemplate.exchange(request, PackageFile[].class).getBody();
return hasPublishedAll(published, requiredDigests);
}
catch (HttpClientErrorException.NotFound ex) {
return false;
}
});
}
catch (ConditionTimeoutException ex) {
logger.debug("Timeout checking Bintray");
return false;
}
return true;
}
private boolean hasPublishedAll(PackageFile[] published, Set<String> requiredDigests) {
if (published == null || published.length == 0) {
logger.debug("Bintray returned no published files");
return false;
}
Set<String> remaining = new HashSet<>(requiredDigests);
for (PackageFile publishedFile : published) {
logger.debug(
"Found published file " + publishedFile.getName() + " with digest " + publishedFile.getSha256());
remaining.remove(publishedFile.getSha256());
}
if (remaining.isEmpty()) {
logger.debug("Found all required digests");
return true;
}
logger.debug(remaining.size() + " digests have not been published:");
remaining.forEach(logger::debug);
return false;
}
private RequestEntity<Void> getPackageFilesRequest(ReleaseInfo releaseInfo, int includeUnpublished) {
return RequestEntity.get(URI.create(BINTRAY_URL + "packages/" + this.bintrayProperties.getSubject() + "/"
+ this.bintrayProperties.getRepo() + "/" + releaseInfo.getGroupId() + "/versions/"
+ releaseInfo.getVersion() + "/files?include_unpublished=" + includeUnpublished)).build();
}
/**
* Add attributes to Spring Boot's Gradle plugin.
* @param releaseInfo the release information
*/
public void publishGradlePlugin(ReleaseInfo releaseInfo) {
logger.debug("Publishing Gradle plugin");
RequestEntity<String> requestEntity = RequestEntity
.post(URI.create(BINTRAY_URL + "packages/" + this.bintrayProperties.getSubject() + "/"
+ this.bintrayProperties.getRepo() + "/" + releaseInfo.getGroupId() + "/versions/"
+ releaseInfo.getVersion() + "/attributes"))
.contentType(MediaType.APPLICATION_JSON).body(GRADLE_PLUGIN_REQUEST);
try {
this.restTemplate.exchange(requestEntity, Object.class);
logger.debug("Publishing Gradle plugin complete");
}
catch (HttpClientErrorException ex) {
logger.info("Failed to add attribute to Gradle plugin");
throw ex;
}
}
/**
* Sync artifacts from Bintray to Maven Central.
* @param releaseInfo the release information
*/
public void syncToMavenCentral(ReleaseInfo releaseInfo) {
logger.info("Calling Bintray to sync to Sonatype");
if (this.sonatypeService.artifactsPublished(releaseInfo)) {
logger.info("Artifacts already published");
return;
}
RequestEntity<SonatypeProperties> requestEntity = RequestEntity
.post(URI.create(String.format(BINTRAY_URL + "maven_central_sync/%s/%s/%s/versions/%s",
this.bintrayProperties.getSubject(), this.bintrayProperties.getRepo(), releaseInfo.getGroupId(),
releaseInfo.getVersion())))
.contentType(MediaType.APPLICATION_JSON).body(this.sonatypeProperties);
try {
this.restTemplate.exchange(requestEntity, Object.class);
logger.debug("Sync complete");
}
catch (HttpClientErrorException ex) {
logger.info("Failed to sync");
throw ex;
}
}
}

@ -1,105 +0,0 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* 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
*
* https://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 io.spring.concourse.releasescripts.command;
import java.io.File;
import java.nio.file.Files;
import java.util.List;
import java.util.Set;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.spring.concourse.releasescripts.ReleaseInfo;
import io.spring.concourse.releasescripts.ReleaseType;
import io.spring.concourse.releasescripts.artifactory.ArtifactoryService;
import io.spring.concourse.releasescripts.artifactory.payload.BuildInfoResponse;
import io.spring.concourse.releasescripts.artifactory.payload.BuildInfoResponse.Artifact;
import io.spring.concourse.releasescripts.artifactory.payload.BuildInfoResponse.BuildInfo;
import io.spring.concourse.releasescripts.artifactory.payload.BuildInfoResponse.Module;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.ApplicationArguments;
import org.springframework.stereotype.Component;
import org.springframework.util.Assert;
/**
* Command used to deploy builds from Artifactory to Bintray.
*
* @author Madhura Bhave
* @author Phillip Webb
*/
@Component
public class DistributeCommand implements Command {
private static final Logger logger = LoggerFactory.getLogger(DistributeCommand.class);
private final ArtifactoryService artifactoryService;
private final ObjectMapper objectMapper;
private final List<Pattern> optionalDeployments;
public DistributeCommand(ArtifactoryService artifactoryService, ObjectMapper objectMapper,
DistributeProperties distributeProperties) {
this.artifactoryService = artifactoryService;
this.objectMapper = objectMapper;
this.optionalDeployments = distributeProperties.getOptionalDeployments().stream().map(Pattern::compile)
.collect(Collectors.toList());
}
@Override
public void run(ApplicationArguments args) throws Exception {
logger.debug("Running 'distribute' command");
List<String> nonOptionArgs = args.getNonOptionArgs();
Assert.state(!nonOptionArgs.isEmpty(), "No command argument specified");
Assert.state(nonOptionArgs.size() == 3, "Release type or build info not specified");
String releaseType = nonOptionArgs.get(1);
ReleaseType type = ReleaseType.from(releaseType);
if (!ReleaseType.RELEASE.equals(type)) {
logger.info("Skipping distribution of " + type + " type");
return;
}
String buildInfoLocation = nonOptionArgs.get(2);
logger.debug("Loading build-info from " + buildInfoLocation);
byte[] content = Files.readAllBytes(new File(buildInfoLocation).toPath());
BuildInfoResponse buildInfoResponse = this.objectMapper.readValue(content, BuildInfoResponse.class);
BuildInfo buildInfo = buildInfoResponse.getBuildInfo();
logger.debug("Loading build info:");
for (Module module : buildInfo.getModules()) {
logger.debug(module.getId());
for (Artifact artifact : module.getArtifacts()) {
logger.debug(artifact.getSha256() + " " + artifact.getName());
}
}
ReleaseInfo releaseInfo = ReleaseInfo.from(buildInfo);
Set<String> artifactDigests = buildInfo.getArtifactDigests(this::isIncluded);
this.artifactoryService.distribute(type.getRepo(), releaseInfo, artifactDigests);
}
private boolean isIncluded(Artifact artifact) {
String path = artifact.getName();
for (Pattern optionalDeployment : this.optionalDeployments) {
if (optionalDeployment.matcher(path).matches()) {
return false;
}
}
return true;
}
}

@ -1,42 +0,0 @@
/*
* Copyright 2020-2020 the original author or authors.
*
* 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
*
* https://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 io.spring.concourse.releasescripts.command;
import java.util.ArrayList;
import java.util.List;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* Distribution properties.
*
* @author Phillip Webb
*/
@ConfigurationProperties(prefix = "distribute")
public class DistributeProperties {
private List<String> optionalDeployments = new ArrayList<>();
public List<String> getOptionalDeployments() {
return this.optionalDeployments;
}
public void setOptionalDeployments(List<String> optionalDeployments) {
this.optionalDeployments = optionalDeployments;
}
}

@ -1,5 +1,5 @@
/*
* Copyright 2012-2020 the original author or authors.
* Copyright 2012-2021 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -24,7 +24,8 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import io.spring.concourse.releasescripts.ReleaseInfo;
import io.spring.concourse.releasescripts.ReleaseType;
import io.spring.concourse.releasescripts.artifactory.payload.BuildInfoResponse;
import io.spring.concourse.releasescripts.bintray.BintrayService;
import io.spring.concourse.releasescripts.artifactory.payload.BuildInfoResponse.BuildInfo;
import io.spring.concourse.releasescripts.sonatype.SonatypeService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@ -33,47 +34,46 @@ import org.springframework.stereotype.Component;
import org.springframework.util.Assert;
/**
* Command used to add attributes to the gradle plugin.
* Command used to publish a release to Maven Central.
*
* @author Madhura Bhave
* @author Andy Wilkinson
*/
@Component
public class PublishGradlePlugin implements Command {
public class PublishToCentralCommand implements Command {
private static final Logger logger = LoggerFactory.getLogger(PublishGradlePlugin.class);
private static final Logger logger = LoggerFactory.getLogger(PublishToCentralCommand.class);
private static final String PUBLISH_GRADLE_PLUGIN_COMMAND = "publishGradlePlugin";
private final BintrayService service;
private final SonatypeService sonatype;
private final ObjectMapper objectMapper;
public PublishGradlePlugin(BintrayService service, ObjectMapper objectMapper) {
this.service = service;
public PublishToCentralCommand(SonatypeService sonatype, ObjectMapper objectMapper) {
this.sonatype = sonatype;
this.objectMapper = objectMapper;
}
@Override
public String getName() {
return PUBLISH_GRADLE_PLUGIN_COMMAND;
return "publishToCentral";
}
@Override
public void run(ApplicationArguments args) throws Exception {
logger.debug("Running 'publish gradle' command");
List<String> nonOptionArgs = args.getNonOptionArgs();
Assert.state(!nonOptionArgs.isEmpty(), "No command argument specified");
Assert.state(nonOptionArgs.size() == 3, "Release type or build info not specified");
Assert.state(nonOptionArgs.size() == 4,
"Release type, build info location, or artifacts location not specified");
String releaseType = nonOptionArgs.get(1);
ReleaseType type = ReleaseType.from(releaseType);
if (!ReleaseType.RELEASE.equals(type)) {
return;
}
String buildInfoLocation = nonOptionArgs.get(2);
logger.debug("Loading build-info from " + buildInfoLocation);
byte[] content = Files.readAllBytes(new File(buildInfoLocation).toPath());
BuildInfoResponse buildInfoResponse = this.objectMapper.readValue(content, BuildInfoResponse.class);
ReleaseInfo releaseInfo = ReleaseInfo.from(buildInfoResponse.getBuildInfo());
this.service.publishGradlePlugin(releaseInfo);
BuildInfo buildInfo = buildInfoResponse.getBuildInfo();
String artifactsLocation = nonOptionArgs.get(3);
this.sonatype.publish(ReleaseInfo.from(buildInfo), new File(artifactsLocation).toPath());
}
}

@ -1,79 +0,0 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* 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
*
* https://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 io.spring.concourse.releasescripts.command;
import java.io.File;
import java.nio.file.Files;
import java.util.List;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.spring.concourse.releasescripts.ReleaseInfo;
import io.spring.concourse.releasescripts.ReleaseType;
import io.spring.concourse.releasescripts.artifactory.payload.BuildInfoResponse;
import io.spring.concourse.releasescripts.bintray.BintrayService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.ApplicationArguments;
import org.springframework.stereotype.Component;
import org.springframework.util.Assert;
/**
* Command used to sync artifacts to Maven Central.
*
* @author Madhura Bhave
*/
@Component
public class SyncToCentralCommand implements Command {
private static final Logger logger = LoggerFactory.getLogger(SyncToCentralCommand.class);
private static final String SYNC_TO_CENTRAL_COMMAND = "syncToCentral";
private final BintrayService service;
private final ObjectMapper objectMapper;
public SyncToCentralCommand(BintrayService service, ObjectMapper objectMapper) {
this.service = service;
this.objectMapper = objectMapper;
}
@Override
public String getName() {
return SYNC_TO_CENTRAL_COMMAND;
}
@Override
public void run(ApplicationArguments args) throws Exception {
logger.debug("Running 'sync to central' command");
List<String> nonOptionArgs = args.getNonOptionArgs();
Assert.state(!nonOptionArgs.isEmpty(), "No command argument specified");
Assert.state(nonOptionArgs.size() == 3, "Release type or build info not specified");
String releaseType = nonOptionArgs.get(1);
ReleaseType type = ReleaseType.from(releaseType);
if (!ReleaseType.RELEASE.equals(type)) {
return;
}
String buildInfoLocation = nonOptionArgs.get(2);
byte[] content = Files.readAllBytes(new File(buildInfoLocation).toPath());
BuildInfoResponse buildInfoResponse = this.objectMapper.readValue(content, BuildInfoResponse.class);
ReleaseInfo releaseInfo = ReleaseInfo.from(buildInfoResponse.getBuildInfo());
this.service.syncToMavenCentral(releaseInfo);
}
}

@ -0,0 +1,64 @@
/*
* Copyright 2021 the original author or authors.
*
* 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
*
* https://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 io.spring.concourse.releasescripts.sonatype;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Collection;
import java.util.List;
import java.util.function.Predicate;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.springframework.core.io.PathResource;
/**
* Collects artifacts to be deployed.
*
* @author Andy Wilkinson
*/
class ArtifactCollector {
private final Predicate<Path> excludeFilter;
ArtifactCollector(List<String> exclude) {
this.excludeFilter = excludeFilter(exclude);
}
private Predicate<Path> excludeFilter(List<String> exclude) {
Predicate<String> patternFilter = exclude.stream().map(Pattern::compile).map(Pattern::asPredicate)
.reduce((path) -> false, Predicate::or).negate();
return (path) -> patternFilter.test(path.toString());
}
Collection<DeployableArtifact> collectArtifacts(Path root) {
try (Stream<Path> artifacts = Files.walk(root)) {
return artifacts.filter(Files::isRegularFile).filter(this.excludeFilter)
.map((artifact) -> deployableArtifact(artifact, root)).collect(Collectors.toList());
}
catch (IOException ex) {
throw new RuntimeException("Could not read artifacts from '" + root + "'");
}
}
private DeployableArtifact deployableArtifact(Path artifact, Path root) {
return new DeployableArtifact(new PathResource(artifact), root.relativize(artifact).toString());
}
}

@ -1,5 +1,5 @@
/*
* Copyright 2020-2020 the original author or authors.
* Copyright 2012-2021 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -14,25 +14,32 @@
* limitations under the License.
*/
package io.spring.concourse.releasescripts.bintray;
package io.spring.concourse.releasescripts.sonatype;
import org.springframework.core.io.Resource;
/**
* Details for a single packaged file.
* An artifact that can be deployed.
*
* @author Phillip Webb
* @author Andy Wilkinson
*/
public class PackageFile {
class DeployableArtifact {
private final Resource resource;
private String name;
private final String path;
private String sha256;
DeployableArtifact(Resource resource, String path) {
this.resource = resource;
this.path = path;
}
public String getName() {
return this.name;
Resource getResource() {
return this.resource;
}
public String getSha256() {
return this.sha256;
String getPath() {
return this.path;
}
}

@ -1,5 +1,5 @@
/*
* Copyright 2012-2019 the original author or authors.
* Copyright 2012-2021 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -16,6 +16,10 @@
package io.spring.concourse.releasescripts.sonatype;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ -34,6 +38,32 @@ public class SonatypeProperties {
@JsonProperty("password")
private String passwordToken;
/**
* URL of the Nexus instance used to publish releases.
*/
private String url;
/**
* ID of the staging profile used to publish releases.
*/
private String stagingProfileId;
/**
* Time between requests made to determine if the closing of a staging repository has
* completed.
*/
private Duration pollingInterval = Duration.ofSeconds(15);
/**
* Number of threads used to upload artifacts to the staging repository.
*/
private int uploadThreads = 8;
/**
* Regular expression patterns of artifacts to exclude
*/
private List<String> exclude = new ArrayList<>();
public String getUserToken() {
return this.userToken;
}
@ -50,4 +80,44 @@ public class SonatypeProperties {
this.passwordToken = passwordToken;
}
public String getUrl() {
return this.url;
}
public void setUrl(String url) {
this.url = url;
}
public String getStagingProfileId() {
return this.stagingProfileId;
}
public void setStagingProfileId(String stagingProfileId) {
this.stagingProfileId = stagingProfileId;
}
public Duration getPollingInterval() {
return this.pollingInterval;
}
public void setPollingInterval(Duration pollingInterval) {
this.pollingInterval = pollingInterval;
}
public int getUploadThreads() {
return this.uploadThreads;
}
public void setUploadThreads(int uploadThreads) {
this.uploadThreads = uploadThreads;
}
public List<String> getExclude() {
return this.exclude;
}
public void setExclude(List<String> exclude) {
this.exclude = exclude;
}
}

@ -1,5 +1,5 @@
/*
* Copyright 2012-2020 the original author or authors.
* Copyright 2012-2021 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -16,7 +16,28 @@
package io.spring.concourse.releasescripts.sonatype;
import java.nio.file.Path;
import java.time.Duration;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonCreator.Mode;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.spring.concourse.releasescripts.ReleaseInfo;
import org.apache.logging.log4j.util.Strings;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@ -32,23 +53,39 @@ import org.springframework.web.client.RestTemplate;
* Central class for interacting with Sonatype.
*
* @author Madhura Bhave
* @author Andy Wilkinson
*/
@Component
public class SonatypeService {
private static final Logger logger = LoggerFactory.getLogger(SonatypeService.class);
private static final String SONATYPE_REPOSITORY_URI = "https://oss.sonatype.org/service/local/repositories/releases/content/org/springframework/boot/spring-boot/";
private static final String NEXUS_REPOSITORY_PATH = "/service/local/repositories/releases/content/org/springframework/boot/spring-boot/";
private static final String NEXUS_STAGING_PATH = "/service/local/staging/";
private final ArtifactCollector artifactCollector;
private final RestTemplate restTemplate;
private final String stagingProfileId;
private final Duration pollingInterval;
private final int threads;
public SonatypeService(RestTemplateBuilder builder, SonatypeProperties sonatypeProperties) {
String username = sonatypeProperties.getUserToken();
String password = sonatypeProperties.getPasswordToken();
if (StringUtils.hasLength(username)) {
builder = builder.basicAuthentication(username, password);
}
this.restTemplate = builder.build();
this.restTemplate = builder.rootUri(sonatypeProperties.getUrl()).build();
this.stagingProfileId = sonatypeProperties.getStagingProfileId();
this.pollingInterval = sonatypeProperties.getPollingInterval();
this.threads = sonatypeProperties.getUploadThreads();
this.artifactCollector = new ArtifactCollector(sonatypeProperties.getExclude());
}
/**
@ -59,7 +96,7 @@ public class SonatypeService {
public boolean artifactsPublished(ReleaseInfo releaseInfo) {
try {
ResponseEntity<?> entity = this.restTemplate
.getForEntity(String.format(SONATYPE_REPOSITORY_URI + "%s/spring-boot-%s.jar.sha1",
.getForEntity(String.format(NEXUS_REPOSITORY_PATH + "%s/spring-boot-%s.jar.sha1",
releaseInfo.getVersion(), releaseInfo.getVersion()), byte[].class);
if (HttpStatus.OK.equals(entity.getStatusCode())) {
logger.info("Already published to Sonatype.");
@ -72,4 +109,200 @@ public class SonatypeService {
return false;
}
/**
* Publishes the release by creating a staging repository and deploying to it the
* artifacts at the given {@code artifactsRoot}. The repository is then closed and,
* upon successfully closure, it is released.
* @param releaseInfo the release information
* @param artifactsRoot the root directory of the artifacts to stage
*/
public void publish(ReleaseInfo releaseInfo, Path artifactsRoot) {
logger.info("Creating staging repository");
String buildId = releaseInfo.getBuildNumber();
String repositoryId = createStagingRepository(buildId);
Collection<DeployableArtifact> artifacts = this.artifactCollector.collectArtifacts(artifactsRoot);
logger.info("Staging repository {} created. Deploying {} artifacts", repositoryId, artifacts.size());
deploy(artifacts, repositoryId);
logger.info("Deploy complete. Closing staging repository");
close(repositoryId);
logger.info("Staging repository closed");
release(repositoryId, buildId);
logger.info("Staging repository released");
}
private String createStagingRepository(String buildId) {
Map<String, Object> body = new HashMap<>();
body.put("data", Collections.singletonMap("description", buildId));
PromoteResponse response = this.restTemplate.postForObject(
String.format(NEXUS_STAGING_PATH + "profiles/%s/start", this.stagingProfileId), body,
PromoteResponse.class);
String repositoryId = response.data.stagedRepositoryId;
return repositoryId;
}
private void deploy(Collection<DeployableArtifact> artifacts, String repositoryId) {
ExecutorService executor = Executors.newFixedThreadPool(this.threads);
try {
CompletableFuture.allOf(artifacts.stream()
.map((artifact) -> CompletableFuture.runAsync(() -> deploy(artifact, repositoryId), executor))
.toArray(CompletableFuture[]::new)).get(60, TimeUnit.MINUTES);
}
catch (InterruptedException ex) {
Thread.currentThread().interrupt();
throw new RuntimeException("Interrupted during artifact deploy");
}
catch (ExecutionException ex) {
throw new RuntimeException("Deploy failed", ex);
}
catch (TimeoutException ex) {
throw new RuntimeException("Deploy timed out", ex);
}
finally {
executor.shutdown();
}
}
private void deploy(DeployableArtifact deployableArtifact, String repositoryId) {
try {
this.restTemplate.put(
NEXUS_STAGING_PATH + "deployByRepositoryId/" + repositoryId + "/" + deployableArtifact.getPath(),
deployableArtifact.getResource());
logger.info("Deloyed {}", deployableArtifact.getPath());
}
catch (HttpClientErrorException ex) {
logger.error("Failed to deploy {}. Error response: {}", deployableArtifact.getPath(),
ex.getResponseBodyAsString());
throw ex;
}
}
private void close(String stagedRepositoryId) {
Map<String, Object> body = new HashMap<>();
body.put("data", Collections.singletonMap("stagedRepositoryId", stagedRepositoryId));
this.restTemplate.postForEntity(String.format(NEXUS_STAGING_PATH + "profiles/%s/finish", this.stagingProfileId),
body, Void.class);
logger.info("Close requested. Awaiting result");
while (true) {
StagingRepository repository = this.restTemplate
.getForObject(NEXUS_STAGING_PATH + "repository/" + stagedRepositoryId, StagingRepository.class);
if (!repository.transitioning) {
if ("open".equals(repository.type)) {
logFailures(stagedRepositoryId);
throw new RuntimeException("Close failed");
}
return;
}
try {
Thread.sleep(this.pollingInterval.toMillis());
}
catch (InterruptedException ex) {
Thread.currentThread().interrupt();
throw new RuntimeException("Interrupted while waiting for staging repository to close", ex);
}
}
}
private void logFailures(String stagedRepositoryId) {
try {
StagingRepositoryActivity[] activities = this.restTemplate.getForObject(
NEXUS_STAGING_PATH + "repository/" + stagedRepositoryId + "/activity",
StagingRepositoryActivity[].class);
List<String> failureMessages = Stream.of(activities).flatMap((activity) -> activity.events.stream())
.filter((event) -> event.severity > 0).flatMap((event) -> event.properties.stream())
.filter((property) -> "failureMessage".equals(property.name))
.map((property) -> " " + property.value).collect(Collectors.toList());
if (failureMessages.isEmpty()) {
logger.error("Close failed for unknown reasons");
}
logger.error("Close failed:\n{}", Strings.join(failureMessages, '\n'));
}
catch (Exception ex) {
logger.error("Failed to determine causes of close failure", ex);
}
}
private void release(String stagedRepositoryId, String buildId) {
Map<String, Object> data = new HashMap<>();
data.put("stagedRepositoryIds", Arrays.asList(stagedRepositoryId));
data.put("description", "Releasing " + buildId);
data.put("autoDropAfterRelease", true);
Map<String, Object> body = Collections.singletonMap("data", data);
this.restTemplate.postForEntity(NEXUS_STAGING_PATH + "bulk/promote", body, Void.class);
}
private static final class PromoteResponse {
private final Data data;
@JsonCreator(mode = Mode.PROPERTIES)
private PromoteResponse(@JsonProperty("data") Data data) {
this.data = data;
}
private static final class Data {
private final String stagedRepositoryId;
@JsonCreator(mode = Mode.PROPERTIES)
Data(@JsonProperty("stagedRepositoryId") String stagedRepositoryId) {
this.stagedRepositoryId = stagedRepositoryId;
}
}
}
private static final class StagingRepository {
private final String type;
private final boolean transitioning;
private StagingRepository(String type, boolean transitioning) {
this.type = type;
this.transitioning = transitioning;
}
}
private static final class StagingRepositoryActivity {
private final List<Event> events;
@JsonCreator
private StagingRepositoryActivity(@JsonProperty("events") List<Event> events) {
this.events = events;
}
private static class Event {
private final List<Property> properties;
private final int severity;
@JsonCreator
public Event(@JsonProperty("name") String name, @JsonProperty("properties") List<Property> properties,
@JsonProperty("severity") int severity) {
this.properties = properties;
this.severity = severity;
}
private static class Property {
private final String name;
private final String value;
@JsonCreator
private Property(@JsonProperty("name") String name, @JsonProperty("value") String value) {
this.name = name;
this.value = value;
}
}
}
}
}

@ -1,4 +1,4 @@
spring.main.banner-mode=off
distribute.optional-deployments[0]=.*\\.zip
distribute.optional-deployments[1]=spring-boot-project-\\d+\\.\\d+\\.\\d+(?:\\.RELEASE)?\\.pom
sonatype.exclude[0]=build-info\\.json
sonatype.exclude[1]=org/springframework/boot/spring-boot-docs/.*
logging.level.io.spring.concourse=DEBUG

@ -16,20 +16,13 @@
package io.spring.concourse.releasescripts.artifactory;
import java.time.Duration;
import java.util.Collections;
import java.util.Set;
import io.spring.concourse.releasescripts.ReleaseInfo;
import io.spring.concourse.releasescripts.bintray.BintrayService;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.mockito.InOrder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.test.autoconfigure.web.client.RestClientTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.core.io.ClassPathResource;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
@ -40,13 +33,6 @@ import org.springframework.util.Base64Utils;
import org.springframework.web.client.HttpClientErrorException;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoMoreInteractions;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.content;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.header;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.method;
@ -63,14 +49,9 @@ import static org.springframework.test.web.client.response.MockRestResponseCreat
@EnableConfigurationProperties(ArtifactoryProperties.class)
class ArtifactoryServiceTests {
private static final Duration TIMEOUT = Duration.ofMinutes(60);
@Autowired
private ArtifactoryService service;
@MockBean
private BintrayService bintrayService;
@Autowired
private ArtifactoryProperties properties;
@ -127,68 +108,6 @@ class ArtifactoryServiceTests {
this.server.verify();
}
@Test
@SuppressWarnings("unchecked")
void distributeWhenSuccessful() throws Exception {
ReleaseInfo releaseInfo = getReleaseInfo();
given(this.bintrayService.isDistributionStarted(eq(releaseInfo))).willReturn(false);
given(this.bintrayService.isDistributionComplete(eq(releaseInfo), (Set<String>) any(), any())).willReturn(true);
this.server.expect(requestTo("https://repo.spring.io/api/build/distribute/example-build/example-build-1"))
.andExpect(method(HttpMethod.POST))
.andExpect(content().json(
"{\"sourceRepos\": [\"libs-release-local\"], \"targetRepo\" : \"spring-distributions\", \"async\":\"true\"}"))
.andExpect(header("Authorization", "Basic " + Base64Utils.encodeToString(String
.format("%s:%s", this.properties.getUsername(), this.properties.getPassword()).getBytes())))
.andExpect(header("Content-Type", MediaType.APPLICATION_JSON.toString())).andRespond(withSuccess());
Set<String> artifactDigests = Collections.singleton("602e20176706d3cc7535f01ffdbe91b270ae5014");
this.service.distribute("libs-release-local", releaseInfo, artifactDigests);
this.server.verify();
InOrder ordered = inOrder(this.bintrayService);
ordered.verify(this.bintrayService).isDistributionComplete(releaseInfo, artifactDigests, TIMEOUT);
}
@Test
void distributeWhenFailure() throws Exception {
ReleaseInfo releaseInfo = getReleaseInfo();
this.server.expect(requestTo("https://repo.spring.io/api/build/distribute/example-build/example-build-1"))
.andExpect(method(HttpMethod.POST))
.andExpect(content().json(
"{\"sourceRepos\": [\"libs-release-local\"], \"targetRepo\" : \"spring-distributions\", \"async\":\"true\"}"))
.andExpect(header("Authorization", "Basic " + Base64Utils.encodeToString(String
.format("%s:%s", this.properties.getUsername(), this.properties.getPassword()).getBytes())))
.andExpect(header("Content-Type", MediaType.APPLICATION_JSON.toString()))
.andRespond(withStatus(HttpStatus.FORBIDDEN));
Set<String> artifactDigests = Collections.singleton("602e20176706d3cc7535f01ffdbe91b270ae5014");
assertThatExceptionOfType(HttpClientErrorException.class)
.isThrownBy(() -> this.service.distribute("libs-release-local", releaseInfo, artifactDigests));
this.server.verify();
verify(this.bintrayService, times(1)).isDistributionStarted(releaseInfo);
verifyNoMoreInteractions(this.bintrayService);
}
@Test
@SuppressWarnings("unchecked")
void distributeWhenGettingPackagesTimesOut() throws Exception {
ReleaseInfo releaseInfo = getReleaseInfo();
given(this.bintrayService.isDistributionComplete(eq(releaseInfo), (Set<String>) any(), any()))
.willReturn(false);
given(this.bintrayService.isDistributionComplete(eq(releaseInfo), (Set<String>) any(), any()))
.willReturn(false);
this.server.expect(requestTo("https://repo.spring.io/api/build/distribute/example-build/example-build-1"))
.andExpect(method(HttpMethod.POST))
.andExpect(content().json(
"{\"sourceRepos\": [\"libs-release-local\"], \"targetRepo\" : \"spring-distributions\", \"async\":\"true\"}"))
.andExpect(header("Authorization", "Basic " + Base64Utils.encodeToString(String
.format("%s:%s", this.properties.getUsername(), this.properties.getPassword()).getBytes())))
.andExpect(header("Content-Type", MediaType.APPLICATION_JSON.toString())).andRespond(withSuccess());
Set<String> artifactDigests = Collections.singleton("602e20176706d3cc7535f01ffdbe91b270ae5014");
assertThatExceptionOfType(DistributionTimeoutException.class)
.isThrownBy(() -> this.service.distribute("libs-release-local", releaseInfo, artifactDigests));
this.server.verify();
InOrder ordered = inOrder(this.bintrayService);
ordered.verify(this.bintrayService).isDistributionComplete(releaseInfo, artifactDigests, TIMEOUT);
}
private ReleaseInfo getReleaseInfo() {
ReleaseInfo releaseInfo = new ReleaseInfo();
releaseInfo.setBuildName("example-build");

@ -1,164 +0,0 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* 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
*
* https://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 io.spring.concourse.releasescripts.bintray;
import java.time.Duration;
import java.util.LinkedHashSet;
import java.util.Set;
import io.spring.concourse.releasescripts.ReleaseInfo;
import io.spring.concourse.releasescripts.sonatype.SonatypeProperties;
import io.spring.concourse.releasescripts.sonatype.SonatypeService;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.test.autoconfigure.web.client.RestClientTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.core.io.ClassPathResource;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.test.web.client.ExpectedCount;
import org.springframework.test.web.client.MockRestServiceServer;
import org.springframework.test.web.client.response.DefaultResponseCreator;
import org.springframework.util.Base64Utils;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.BDDMockito.given;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.content;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.header;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.method;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
/**
* Tests for {@link BintrayService}.
*
* @author Madhura Bhave
*/
@RestClientTest(BintrayService.class)
@EnableConfigurationProperties({ BintrayProperties.class, SonatypeProperties.class })
class BintrayServiceTests {
@Autowired
private BintrayService service;
@Autowired
private BintrayProperties properties;
@Autowired
private SonatypeProperties sonatypeProperties;
@MockBean
private SonatypeService sonatypeService;
@Autowired
private MockRestServiceServer server;
@AfterEach
void tearDown() {
this.server.reset();
}
@Test
void isDistributionComplete() throws Exception {
setupGetPackageFiles(1, "no-package-files.json");
setupGetPackageFiles(1, "some-package-files.json");
setupGetPackageFiles(1, "all-package-files.json");
Set<String> digests = new LinkedHashSet<>();
digests.add("602e20176706d3cc7535f01ffdbe91b270ae5012");
digests.add("602e20176706d3cc7535f01ffdbe91b270ae5013");
digests.add("602e20176706d3cc7535f01ffdbe91b270ae5014");
assertThat(this.service.isDistributionComplete(getReleaseInfo(), digests, Duration.ofMinutes(1), Duration.ZERO))
.isTrue();
this.server.verify();
}
private void setupGetPackageFiles(int includeUnpublished, String path) {
this.server
.expect(requestTo(String.format(
"https://api.bintray.com/packages/%s/%s/%s/versions/%s/files?include_unpublished=%s",
this.properties.getSubject(), this.properties.getRepo(), "example", "1.1.0.RELEASE",
includeUnpublished)))
.andExpect(method(HttpMethod.GET))
.andExpect(header("Authorization", "Basic " + Base64Utils.encodeToString(
String.format("%s:%s", this.properties.getUsername(), this.properties.getApiKey()).getBytes())))
.andRespond(withJsonFrom(path));
}
@Test
void publishGradlePluginWhenSuccessful() {
this.server
.expect(requestTo(String.format("https://api.bintray.com/packages/%s/%s/%s/versions/%s/attributes",
this.properties.getSubject(), this.properties.getRepo(), "example", "1.1.0.RELEASE")))
.andExpect(method(HttpMethod.POST))
.andExpect(content().json(
"[ { \"name\": \"gradle-plugin\", \"values\": [\"org.springframework.boot:org.springframework.boot:spring-boot-gradle-plugin\"] } ]"))
.andExpect(header("Authorization", "Basic " + Base64Utils.encodeToString(
String.format("%s:%s", this.properties.getUsername(), this.properties.getApiKey()).getBytes())))
.andExpect(header("Content-Type", MediaType.APPLICATION_JSON.toString())).andRespond(withSuccess());
this.service.publishGradlePlugin(getReleaseInfo());
this.server.verify();
}
@Test
void syncToMavenCentralWhenSuccessful() {
ReleaseInfo releaseInfo = getReleaseInfo();
given(this.sonatypeService.artifactsPublished(releaseInfo)).willReturn(false);
this.server
.expect(requestTo(String.format("https://api.bintray.com/maven_central_sync/%s/%s/%s/versions/%s",
this.properties.getSubject(), this.properties.getRepo(), "example", "1.1.0.RELEASE")))
.andExpect(method(HttpMethod.POST))
.andExpect(content().json(String.format("{\"username\": \"%s\", \"password\": \"%s\"}",
this.sonatypeProperties.getUserToken(), this.sonatypeProperties.getPasswordToken())))
.andExpect(header("Authorization", "Basic " + Base64Utils.encodeToString(
String.format("%s:%s", this.properties.getUsername(), this.properties.getApiKey()).getBytes())))
.andExpect(header("Content-Type", MediaType.APPLICATION_JSON.toString())).andRespond(withSuccess());
this.service.syncToMavenCentral(releaseInfo);
this.server.verify();
}
@Test
void syncToMavenCentralWhenArtifactsAlreadyPublished() {
ReleaseInfo releaseInfo = getReleaseInfo();
given(this.sonatypeService.artifactsPublished(releaseInfo)).willReturn(true);
this.server.expect(ExpectedCount.never(),
requestTo(String.format("https://api.bintray.com/maven_central_sync/%s/%s/%s/versions/%s",
this.properties.getSubject(), this.properties.getRepo(), "example", "1.1.0.RELEASE")));
this.service.syncToMavenCentral(releaseInfo);
this.server.verify();
}
private ReleaseInfo getReleaseInfo() {
ReleaseInfo releaseInfo = new ReleaseInfo();
releaseInfo.setBuildName("example-build");
releaseInfo.setBuildNumber("example-build-1");
releaseInfo.setGroupId("example");
releaseInfo.setVersion("1.1.0.RELEASE");
return releaseInfo;
}
private DefaultResponseCreator withJsonFrom(String path) {
return withSuccess(getClassPathResource(path), MediaType.APPLICATION_JSON);
}
private ClassPathResource getClassPathResource(String path) {
return new ClassPathResource(path, getClass());
}
}

@ -1,71 +0,0 @@
/*
* Copyright 2012-2019 the original author or authors.
*
* 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
*
* https://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 io.spring.concourse.releasescripts.command;
import java.util.Arrays;
import java.util.Collections;
import org.junit.jupiter.api.Test;
import org.springframework.boot.DefaultApplicationArguments;
import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
/**
* Tests for {@link CommandProcessor}.
*
* @author Madhura Bhave
*/
class CommandProcessorTests {
private static final String[] NO_ARGS = {};
@Test
void runWhenNoArgumentThrowsException() {
CommandProcessor processor = new CommandProcessor(Collections.singletonList(mock(Command.class)));
assertThatIllegalStateException().isThrownBy(() -> processor.run(new DefaultApplicationArguments(NO_ARGS)))
.withMessage("No command argument specified");
}
@Test
void runWhenUnknownCommandThrowsException() {
Command fooCommand = mock(Command.class);
given(fooCommand.getName()).willReturn("foo");
CommandProcessor processor = new CommandProcessor(Collections.singletonList(fooCommand));
DefaultApplicationArguments args = new DefaultApplicationArguments(new String[] { "bar", "go" });
assertThatIllegalStateException().isThrownBy(() -> processor.run(args)).withMessage("Unknown command 'bar'");
}
@Test
void runDelegatesToCommand() throws Exception {
Command fooCommand = mock(Command.class);
given(fooCommand.getName()).willReturn("foo");
Command barCommand = mock(Command.class);
given(barCommand.getName()).willReturn("bar");
CommandProcessor processor = new CommandProcessor(Arrays.asList(fooCommand, barCommand));
DefaultApplicationArguments args = new DefaultApplicationArguments(new String[] { "bar", "go" });
processor.run(args);
verify(fooCommand, never()).run(any());
verify(barCommand).run(args);
}
}

@ -1,127 +0,0 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* 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
*
* https://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 io.spring.concourse.releasescripts.command;
import java.util.Arrays;
import java.util.Set;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.spring.concourse.releasescripts.ReleaseInfo;
import io.spring.concourse.releasescripts.ReleaseType;
import io.spring.concourse.releasescripts.artifactory.ArtifactoryService;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.springframework.boot.DefaultApplicationArguments;
import org.springframework.core.io.ClassPathResource;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
/**
* Tests for {@link DistributeCommand}.
*
* @author Madhura Bhave
* @author Phillip Webb
*/
class DistributeCommandTests {
@Mock
private ArtifactoryService service;
private DistributeCommand command;
private ObjectMapper objectMapper;
@BeforeEach
void setup() {
MockitoAnnotations.initMocks(this);
DistributeProperties distributeProperties = new DistributeProperties();
distributeProperties.setOptionalDeployments(Arrays.asList(".*\\.zip", "demo-\\d\\.\\d\\.\\d\\.doc"));
this.objectMapper = new ObjectMapper().disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
this.command = new DistributeCommand(this.service, this.objectMapper, distributeProperties);
}
@Test
void distributeWhenReleaseTypeNotSpecifiedShouldThrowException() {
Assertions.assertThatIllegalStateException()
.isThrownBy(() -> this.command.run(new DefaultApplicationArguments("distribute")));
}
@Test
void distributeWhenReleaseTypeMilestoneShouldDoNothing() throws Exception {
this.command.run(new DefaultApplicationArguments("distribute", "M", getBuildInfoLocation()));
verifyNoInteractions(this.service);
}
@Test
void distributeWhenReleaseTypeRCShouldDoNothing() throws Exception {
this.command.run(new DefaultApplicationArguments("distribute", "RC", getBuildInfoLocation()));
verifyNoInteractions(this.service);
}
@Test
@SuppressWarnings("unchecked")
void distributeWhenReleaseTypeReleaseShouldCallService() throws Exception {
ArgumentCaptor<ReleaseInfo> releaseInfoCaptor = ArgumentCaptor.forClass(ReleaseInfo.class);
ArgumentCaptor<Set<String>> artifactDigestCaptor = ArgumentCaptor.forClass(Set.class);
this.command.run(new DefaultApplicationArguments("distribute", "RELEASE", getBuildInfoLocation()));
verify(this.service).distribute(eq(ReleaseType.RELEASE.getRepo()), releaseInfoCaptor.capture(),
artifactDigestCaptor.capture());
ReleaseInfo releaseInfo = releaseInfoCaptor.getValue();
assertThat(releaseInfo.getBuildName()).isEqualTo("example");
assertThat(releaseInfo.getBuildNumber()).isEqualTo("example-build-1");
assertThat(releaseInfo.getGroupId()).isEqualTo("org.example.demo");
assertThat(releaseInfo.getVersion()).isEqualTo("2.2.0");
Set<String> artifactDigests = artifactDigestCaptor.getValue();
assertThat(artifactDigests).containsExactly("aaaaaaaaa85f5c5093721f3ed0edda8ff8290yyyyyyyyyy");
}
@Test
@SuppressWarnings("unchecked")
void distributeWhenReleaseTypeReleaseAndFilteredShouldCallService() throws Exception {
ArgumentCaptor<ReleaseInfo> releaseInfoCaptor = ArgumentCaptor.forClass(ReleaseInfo.class);
ArgumentCaptor<Set<String>> artifactDigestCaptor = ArgumentCaptor.forClass(Set.class);
this.command.run(new DefaultApplicationArguments("distribute", "RELEASE",
getBuildInfoLocation("filtered-build-info-response.json")));
verify(this.service).distribute(eq(ReleaseType.RELEASE.getRepo()), releaseInfoCaptor.capture(),
artifactDigestCaptor.capture());
ReleaseInfo releaseInfo = releaseInfoCaptor.getValue();
assertThat(releaseInfo.getBuildName()).isEqualTo("example");
assertThat(releaseInfo.getBuildNumber()).isEqualTo("example-build-1");
assertThat(releaseInfo.getGroupId()).isEqualTo("org.example.demo");
assertThat(releaseInfo.getVersion()).isEqualTo("2.2.0");
Set<String> artifactDigests = artifactDigestCaptor.getValue();
assertThat(artifactDigests).containsExactly("aaaaaaaaa85f5c5093721f3ed0edda8ff8290yyyyyyyyyy");
}
private String getBuildInfoLocation() throws Exception {
return getBuildInfoLocation("build-info-response.json");
}
private String getBuildInfoLocation(String file) throws Exception {
return new ClassPathResource(file, ArtifactoryService.class).getFile().getAbsolutePath();
}
}

@ -1,104 +0,0 @@
/*
* Copyright 2012-2019 the original author or authors.
*
* 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
*
* https://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 io.spring.concourse.releasescripts.command;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.spring.concourse.releasescripts.ReleaseInfo;
import io.spring.concourse.releasescripts.ReleaseType;
import io.spring.concourse.releasescripts.artifactory.ArtifactoryService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.springframework.boot.DefaultApplicationArguments;
import org.springframework.core.io.ClassPathResource;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
/**
* @author Madhura Bhave
*/
class PromoteCommandTests {
@Mock
private ArtifactoryService service;
private PromoteCommand command;
private ObjectMapper objectMapper;
@BeforeEach
void setup() {
MockitoAnnotations.initMocks(this);
this.objectMapper = new ObjectMapper().disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
this.command = new PromoteCommand(this.service, this.objectMapper);
}
@Test
void runWhenReleaseTypeNotSpecifiedShouldThrowException() {
assertThatIllegalStateException()
.isThrownBy(() -> this.command.run(new DefaultApplicationArguments("promote")));
}
@Test
void runWhenReleaseTypeMilestoneShouldCallService() throws Exception {
this.command.run(new DefaultApplicationArguments("promote", "M", getBuildInfoLocation()));
verify(this.service).promote(eq(ReleaseType.MILESTONE.getRepo()), any(ReleaseInfo.class));
}
@Test
void runWhenReleaseTypeRCShouldCallService() throws Exception {
this.command.run(new DefaultApplicationArguments("promote", "RC", getBuildInfoLocation()));
verify(this.service).promote(eq(ReleaseType.RELEASE_CANDIDATE.getRepo()), any(ReleaseInfo.class));
}
@Test
void runWhenReleaseTypeReleaseShouldCallService() throws Exception {
this.command.run(new DefaultApplicationArguments("promote", "RELEASE", getBuildInfoLocation()));
verify(this.service).promote(eq(ReleaseType.RELEASE.getRepo()), any(ReleaseInfo.class));
}
@Test
void runWhenBuildInfoNotSpecifiedShouldThrowException() {
assertThatIllegalStateException()
.isThrownBy(() -> this.command.run(new DefaultApplicationArguments("promote", "M")));
}
@Test
void runShouldParseBuildInfoProperly() throws Exception {
ArgumentCaptor<ReleaseInfo> captor = ArgumentCaptor.forClass(ReleaseInfo.class);
this.command.run(new DefaultApplicationArguments("promote", "RELEASE", getBuildInfoLocation()));
verify(this.service).promote(eq(ReleaseType.RELEASE.getRepo()), captor.capture());
ReleaseInfo releaseInfo = captor.getValue();
assertThat(releaseInfo.getBuildName()).isEqualTo("example");
assertThat(releaseInfo.getBuildNumber()).isEqualTo("example-build-1");
assertThat(releaseInfo.getGroupId()).isEqualTo("org.example.demo");
assertThat(releaseInfo.getVersion()).isEqualTo("2.2.0");
}
private String getBuildInfoLocation() throws Exception {
return new ClassPathResource("build-info-response.json", ArtifactoryService.class).getFile().getAbsolutePath();
}
}

@ -1,93 +0,0 @@
/*
* Copyright 2012-2019 the original author or authors.
*
* 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
*
* https://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 io.spring.concourse.releasescripts.command;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.spring.concourse.releasescripts.ReleaseInfo;
import io.spring.concourse.releasescripts.artifactory.ArtifactoryService;
import io.spring.concourse.releasescripts.bintray.BintrayService;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.springframework.boot.DefaultApplicationArguments;
import org.springframework.core.io.ClassPathResource;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
/**
* Tests for {@link PublishGradlePlugin}.
*
* @author Madhura Bhave
*/
class PublishGradlePluginTests {
@Mock
private BintrayService service;
private PublishGradlePlugin command;
private ObjectMapper objectMapper;
@BeforeEach
void setup() {
MockitoAnnotations.initMocks(this);
this.objectMapper = new ObjectMapper().disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
this.command = new PublishGradlePlugin(this.service, objectMapper);
}
@Test
void runWhenReleaseTypeNotSpecifiedShouldThrowException() throws Exception {
Assertions.assertThatIllegalStateException()
.isThrownBy(() -> this.command.run(new DefaultApplicationArguments("publishGradlePlugin")));
}
@Test
void runWhenReleaseTypeMilestoneShouldDoNothing() throws Exception {
this.command.run(new DefaultApplicationArguments("publishGradlePlugin", "M", getBuildInfoLocation()));
verifyNoInteractions(this.service);
}
@Test
void runWhenReleaseTypeRCShouldDoNothing() throws Exception {
this.command.run(new DefaultApplicationArguments("publishGradlePlugin", "RC", getBuildInfoLocation()));
verifyNoInteractions(this.service);
}
@Test
void runWhenReleaseTypeReleaseShouldCallService() throws Exception {
ArgumentCaptor<ReleaseInfo> captor = ArgumentCaptor.forClass(ReleaseInfo.class);
this.command.run(new DefaultApplicationArguments("promote", "RELEASE", getBuildInfoLocation()));
verify(this.service).publishGradlePlugin(captor.capture());
ReleaseInfo releaseInfo = captor.getValue();
assertThat(releaseInfo.getBuildName()).isEqualTo("example");
assertThat(releaseInfo.getBuildNumber()).isEqualTo("example-build-1");
assertThat(releaseInfo.getGroupId()).isEqualTo("org.example.demo");
assertThat(releaseInfo.getVersion()).isEqualTo("2.2.0");
}
private String getBuildInfoLocation() throws Exception {
return new ClassPathResource("build-info-response.json", ArtifactoryService.class).getFile().getAbsolutePath();
}
}

@ -1,98 +0,0 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* 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
*
* https://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 io.spring.concourse.releasescripts.command;
import io.spring.concourse.releasescripts.sdkman.SdkmanService;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.springframework.boot.DefaultApplicationArguments;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
/**
* Tests for {@link PublishToSdkmanCommand}.
*
* @author Madhura Bhave
*/
class PublishToSdkmanCommandTests {
@Mock
private SdkmanService service;
private PublishToSdkmanCommand command;
@BeforeEach
void setup() {
MockitoAnnotations.initMocks(this);
this.command = new PublishToSdkmanCommand(this.service);
}
@Test
void runWhenReleaseTypeNotSpecifiedShouldThrowException() throws Exception {
Assertions.assertThatIllegalStateException()
.isThrownBy(() -> this.command.run(new DefaultApplicationArguments("publishToSdkman")));
}
@Test
void runWhenVersionNotSpecifiedShouldThrowException() throws Exception {
Assertions.assertThatIllegalStateException()
.isThrownBy(() -> this.command.run(new DefaultApplicationArguments("publishToSdkman", "RELEASE")));
}
@Test
void runWhenReleaseTypeMilestoneShouldDoNothing() throws Exception {
this.command.run(new DefaultApplicationArguments("publishToSdkman", "M", "1.2.3"));
verifyNoInteractions(this.service);
}
@Test
void runWhenReleaseTypeRCShouldDoNothing() throws Exception {
this.command.run(new DefaultApplicationArguments("publishToSdkman", "RC", "1.2.3"));
verifyNoInteractions(this.service);
}
@Test
void runWhenLatestGANotSpecifiedShouldCallServiceWithMakeDefaultFalse() throws Exception {
DefaultApplicationArguments args = new DefaultApplicationArguments("promote", "RELEASE", "1.2.3");
testRun(args, false);
}
@Test
void runWhenReleaseTypeReleaseShouldCallService() throws Exception {
DefaultApplicationArguments args = new DefaultApplicationArguments("promote", "RELEASE", "1.2.3", "true");
testRun(args, true);
}
private void testRun(DefaultApplicationArguments args, boolean makeDefault) throws Exception {
ArgumentCaptor<String> versionCaptor = ArgumentCaptor.forClass(String.class);
ArgumentCaptor<Boolean> makeDefaultCaptor = ArgumentCaptor.forClass(Boolean.class);
this.command.run(args);
verify(this.service).publish(versionCaptor.capture(), makeDefaultCaptor.capture());
String version = versionCaptor.getValue();
Boolean makeDefaultValue = makeDefaultCaptor.getValue();
assertThat(version).isEqualTo("1.2.3");
assertThat(makeDefaultValue).isEqualTo(makeDefault);
}
}

@ -1,93 +0,0 @@
/*
* Copyright 2012-2019 the original author or authors.
*
* 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
*
* https://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 io.spring.concourse.releasescripts.command;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.spring.concourse.releasescripts.ReleaseInfo;
import io.spring.concourse.releasescripts.artifactory.ArtifactoryService;
import io.spring.concourse.releasescripts.bintray.BintrayService;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.springframework.boot.DefaultApplicationArguments;
import org.springframework.core.io.ClassPathResource;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
/**
* Tests for {@link SyncToCentralCommand}.
*
* @author Madhura Bhave
*/
class SyncToCentralCommandTests {
@Mock
private BintrayService service;
private SyncToCentralCommand command;
private ObjectMapper objectMapper;
@BeforeEach
void setup() {
MockitoAnnotations.initMocks(this);
this.objectMapper = new ObjectMapper().disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
this.command = new SyncToCentralCommand(this.service, objectMapper);
}
@Test
void runWhenReleaseTypeNotSpecifiedShouldThrowException() throws Exception {
Assertions.assertThatIllegalStateException()
.isThrownBy(() -> this.command.run(new DefaultApplicationArguments("syncToCentral")));
}
@Test
void runWhenReleaseTypeMilestoneShouldDoNothing() throws Exception {
this.command.run(new DefaultApplicationArguments("syncToCentral", "M", getBuildInfoLocation()));
verifyNoInteractions(this.service);
}
@Test
void runWhenReleaseTypeRCShouldDoNothing() throws Exception {
this.command.run(new DefaultApplicationArguments("syncToCentral", "RC", getBuildInfoLocation()));
verifyNoInteractions(this.service);
}
@Test
void runWhenReleaseTypeReleaseShouldCallService() throws Exception {
ArgumentCaptor<ReleaseInfo> captor = ArgumentCaptor.forClass(ReleaseInfo.class);
this.command.run(new DefaultApplicationArguments("syncToCentral", "RELEASE", getBuildInfoLocation()));
verify(this.service).syncToMavenCentral(captor.capture());
ReleaseInfo releaseInfo = captor.getValue();
assertThat(releaseInfo.getBuildName()).isEqualTo("example");
assertThat(releaseInfo.getBuildNumber()).isEqualTo("example-build-1");
assertThat(releaseInfo.getGroupId()).isEqualTo("org.example.demo");
assertThat(releaseInfo.getVersion()).isEqualTo("2.2.0");
}
private String getBuildInfoLocation() throws Exception {
return new ClassPathResource("build-info-response.json", ArtifactoryService.class).getFile().getAbsolutePath();
}
}

@ -1,5 +1,5 @@
/*
* Copyright 2012-2020 the original author or authors.
* Copyright 2012-2021 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -16,6 +16,17 @@
package io.spring.concourse.releasescripts.sonatype;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Iterator;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import io.spring.concourse.releasescripts.ReleaseInfo;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
@ -23,11 +34,20 @@ import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.test.autoconfigure.web.client.RestClientTest;
import org.springframework.core.io.FileSystemResource;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.client.ClientHttpRequest;
import org.springframework.test.web.client.ExpectedCount;
import org.springframework.test.web.client.MockRestServiceServer;
import org.springframework.test.web.client.RequestMatcher;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
import static org.hamcrest.Matchers.equalTo;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.header;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.jsonPath;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.method;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus;
@ -38,7 +58,7 @@ import static org.springframework.test.web.client.response.MockRestResponseCreat
*
* @author Madhura Bhave
*/
@RestClientTest(SonatypeService.class)
@RestClientTest(components = SonatypeService.class, properties = "sonatype.url=https://nexus.example.org")
@EnableConfigurationProperties(SonatypeProperties.class)
class SonatypeServiceTests {
@ -56,9 +76,9 @@ class SonatypeServiceTests {
@Test
void artifactsPublishedWhenPublishedShouldReturnTrue() {
this.server.expect(requestTo(String.format(
"https://oss.sonatype.org/service/local/repositories/releases/content/org/springframework/boot/spring-boot/%s/spring-boot-%s.jar.sha1",
"/service/local/repositories/releases/content/org/springframework/boot/spring-boot/%s/spring-boot-%s.jar.sha1",
"1.1.0.RELEASE", "1.1.0.RELEASE"))).andExpect(method(HttpMethod.GET))
.andRespond(withSuccess().body("ce8d8b6838ecceb68962b975b18682f4237ccf71".getBytes()));
.andRespond(withSuccess().body("ce8d8b6838ecceb68962b9150b18682f4237ccf71".getBytes()));
boolean published = this.service.artifactsPublished(getReleaseInfo());
assertThat(published).isTrue();
this.server.verify();
@ -67,7 +87,7 @@ class SonatypeServiceTests {
@Test
void artifactsPublishedWhenNotPublishedShouldReturnFalse() {
this.server.expect(requestTo(String.format(
"https://oss.sonatype.org/service/local/repositories/releases/content/org/springframework/boot/spring-boot/%s/spring-boot-%s.jar.sha1",
"/service/local/repositories/releases/content/org/springframework/boot/spring-boot/%s/spring-boot-%s.jar.sha1",
"1.1.0.RELEASE", "1.1.0.RELEASE"))).andExpect(method(HttpMethod.GET))
.andRespond(withStatus(HttpStatus.NOT_FOUND));
boolean published = this.service.artifactsPublished(getReleaseInfo());
@ -75,6 +95,102 @@ class SonatypeServiceTests {
this.server.verify();
}
@Test
void publishWithSuccessfulClose() throws IOException {
this.server.expect(requestTo("/service/local/staging/profiles/1a2b3c4d/start"))
.andExpect(method(HttpMethod.POST)).andExpect(header("Content-Type", "application/json"))
.andExpect(header("Accept", "application/json, application/*+json"))
.andExpect(jsonPath("$.data.description").value("example-build-1"))
.andRespond(withStatus(HttpStatus.CREATED).contentType(MediaType.APPLICATION_JSON).body(
"{\"data\":{\"stagedRepositoryId\":\"example-6789\", \"description\":\"example-build\"}}"));
Path artifactsRoot = new File("src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo")
.toPath();
try (Stream<Path> artifacts = Files.walk(artifactsRoot)) {
Set<RequestMatcher> uploads = artifacts.filter(Files::isRegularFile)
.map((artifact) -> artifactsRoot.relativize(artifact))
.filter((artifact) -> !artifact.startsWith("build-info.json"))
.map((artifact) -> requestTo(
"/service/local/staging/deployByRepositoryId/example-6789/" + artifact.toString()))
.collect(Collectors.toCollection(HashSet::new));
AnyOfRequestMatcher uploadRequestsMatcher = anyOf(uploads);
assertThat(uploadRequestsMatcher.candidates).hasSize(150);
this.server.expect(ExpectedCount.times(150), uploadRequestsMatcher).andExpect(method(HttpMethod.PUT))
.andRespond(withSuccess());
this.server.expect(requestTo("/service/local/staging/profiles/1a2b3c4d/finish"))
.andExpect(method(HttpMethod.POST)).andExpect(header("Content-Type", "application/json"))
.andExpect(header("Accept", "application/json, application/*+json"))
.andRespond(withStatus(HttpStatus.CREATED));
this.server.expect(ExpectedCount.times(2), requestTo("/service/local/staging/repository/example-6789"))
.andExpect(method(HttpMethod.GET))
.andExpect(header("Accept", "application/json, application/*+json"))
.andRespond(withSuccess().contentType(MediaType.APPLICATION_JSON)
.body("{\"type\":\"open\", \"transitioning\":true}"));
this.server.expect(requestTo("/service/local/staging/repository/example-6789"))
.andExpect(method(HttpMethod.GET))
.andExpect(header("Accept", "application/json, application/*+json"))
.andRespond(withSuccess().contentType(MediaType.APPLICATION_JSON)
.body("{\"type\":\"closed\", \"transitioning\":false}"));
this.server.expect(requestTo("/service/local/staging/bulk/promote")).andExpect(method(HttpMethod.POST))
.andExpect(header("Content-Type", "application/json"))
.andExpect(header("Accept", "application/json, application/*+json"))
.andExpect(jsonPath("$.data.description").value("Releasing example-build-1"))
.andExpect(jsonPath("$.data.autoDropAfterRelease").value(true))
.andExpect(jsonPath("$.data.stagedRepositoryIds").value(equalTo(Arrays.asList("example-6789"))))
.andRespond(withSuccess());
this.service.publish(getReleaseInfo(), artifactsRoot);
this.server.verify();
assertThat(uploadRequestsMatcher.candidates).hasSize(0);
}
}
@Test
void publishWithCloseFailureDueToRuleViolations() throws IOException {
this.server.expect(requestTo("/service/local/staging/profiles/1a2b3c4d/start"))
.andExpect(method(HttpMethod.POST)).andExpect(header("Content-Type", "application/json"))
.andExpect(header("Accept", "application/json, application/*+json"))
.andExpect(jsonPath("$.data.description").value("example-build-1"))
.andRespond(withStatus(HttpStatus.CREATED).contentType(MediaType.APPLICATION_JSON).body(
"{\"data\":{\"stagedRepositoryId\":\"example-6789\", \"description\":\"example-build\"}}"));
Path artifactsRoot = new File("src/test/resources/io/spring/concourse/releasescripts/sonatype/artifactory-repo")
.toPath();
try (Stream<Path> artifacts = Files.walk(artifactsRoot)) {
Set<RequestMatcher> uploads = artifacts.filter(Files::isRegularFile)
.map((artifact) -> artifactsRoot.relativize(artifact))
.filter((artifact) -> !"build-info.json".equals(artifact.toString()))
.map((artifact) -> requestTo(
"/service/local/staging/deployByRepositoryId/example-6789/" + artifact.toString()))
.collect(Collectors.toCollection(HashSet::new));
AnyOfRequestMatcher uploadRequestsMatcher = anyOf(uploads);
assertThat(uploadRequestsMatcher.candidates).hasSize(150);
this.server.expect(ExpectedCount.times(150), uploadRequestsMatcher).andExpect(method(HttpMethod.PUT))
.andRespond(withSuccess());
this.server.expect(requestTo("/service/local/staging/profiles/1a2b3c4d/finish"))
.andExpect(method(HttpMethod.POST)).andExpect(header("Content-Type", "application/json"))
.andExpect(header("Accept", "application/json, application/*+json"))
.andRespond(withStatus(HttpStatus.CREATED));
this.server.expect(ExpectedCount.times(2), requestTo("/service/local/staging/repository/example-6789"))
.andExpect(method(HttpMethod.GET))
.andExpect(header("Accept", "application/json, application/*+json"))
.andRespond(withSuccess().contentType(MediaType.APPLICATION_JSON)
.body("{\"type\":\"open\", \"transitioning\":true}"));
this.server.expect(requestTo("/service/local/staging/repository/example-6789"))
.andExpect(method(HttpMethod.GET))
.andExpect(header("Accept", "application/json, application/*+json"))
.andRespond(withSuccess().contentType(MediaType.APPLICATION_JSON)
.body("{\"type\":\"open\", \"transitioning\":false}"));
this.server.expect(requestTo("/service/local/staging/repository/example-6789/activity"))
.andExpect(method(HttpMethod.GET))
.andExpect(header("Accept", "application/json, application/*+json"))
.andRespond(withSuccess().contentType(MediaType.APPLICATION_JSON).body(new FileSystemResource(
new File("src/test/resources/io/spring/concourse/releasescripts/sonatype/activity.json"))));
assertThatExceptionOfType(RuntimeException.class)
.isThrownBy(() -> this.service.publish(getReleaseInfo(), artifactsRoot))
.withMessage("Close failed");
this.server.verify();
assertThat(uploadRequestsMatcher.candidates).hasSize(0);
}
}
private ReleaseInfo getReleaseInfo() {
ReleaseInfo releaseInfo = new ReleaseInfo();
releaseInfo.setBuildName("example-build");
@ -84,4 +200,39 @@ class SonatypeServiceTests {
return releaseInfo;
}
private AnyOfRequestMatcher anyOf(Set<RequestMatcher> candidates) {
return new AnyOfRequestMatcher(candidates);
}
private static class AnyOfRequestMatcher implements RequestMatcher {
private final Object monitor = new Object();
private final Set<RequestMatcher> candidates;
private AnyOfRequestMatcher(Set<RequestMatcher> candidates) {
this.candidates = candidates;
}
@Override
public void match(ClientHttpRequest request) throws IOException, AssertionError {
synchronized (this.monitor) {
Iterator<RequestMatcher> iterator = this.candidates.iterator();
while (iterator.hasNext()) {
try {
iterator.next().match(request);
iterator.remove();
return;
}
catch (AssertionError ex) {
// Continue
}
}
throw new AssertionError(
"No matching request matcher was found for request to '" + request.getURI() + "'");
}
}
}
}

@ -9,6 +9,8 @@ bintray:
sonatype:
user-token: sonatype-user
password-token: sonatype-password
polling-interval: 1s
staging-profile-id: 1a2b3c4d
sdkman:
consumer-key: sdkman-consumer-key
consumer-token: sdkman-consumer-token

@ -1,35 +0,0 @@
[
{
"name": "nutcracker-1.1-sources.jar",
"path": "org/jfrog/powerutils/nutcracker/1.1/nutcracker-1.1-sources.jar",
"package": "jfrog-power-utils",
"version": "1.1",
"repo": "jfrog-jars",
"owner": "jfrog",
"created": "ISO8601 (yyyy-MM-dd'T'HH:mm:ss.SSSZ)",
"size": 1234,
"sha256": "602e20176706d3cc7535f01ffdbe91b270ae5012"
},
{
"name": "nutcracker-1.1.pom",
"path": "org/jfrog/powerutils/nutcracker/1.1/nutcracker-1.1.pom",
"package": "jfrog-power-utils",
"version": "1.1",
"repo": "jfrog-jars",
"owner": "jfrog",
"created": "ISO8601 (yyyy-MM-dd'T'HH:mm:ss.SSSZ)",
"size": 1234,
"sha256": "602e20176706d3cc7535f01ffdbe91b270ae5013"
},
{
"name": "nutcracker-1.1.jar",
"path": "org/jfrog/powerutils/nutcracker/1.1/nutcracker-1.1.jar",
"package": "jfrog-power-utils",
"version": "1.1",
"repo": "jfrog-jars",
"owner": "jfrog",
"created": "ISO8601 (yyyy-MM-dd'T'HH:mm:ss.SSSZ)",
"size": 1234,
"sha256": "602e20176706d3cc7535f01ffdbe91b270ae5014"
}
]

@ -1,13 +0,0 @@
[
{
"name": "nutcracker-1.1-sources.jar",
"path": "org/jfrog/powerutils/nutcracker/1.1/nutcracker-1.1-sources.jar",
"package": "jfrog-power-utils",
"version": "1.1",
"repo": "jfrog-jars",
"owner": "jfrog",
"created": "ISO8601 (yyyy-MM-dd'T'HH:mm:ss.SSSZ)",
"size": 1234,
"sha256": "602e20176706d3cc7535f01ffdbe91b270ae5012"
}
]

@ -0,0 +1,362 @@
[
{
"events": [
{
"name": "repositoryCreated",
"properties": [
{
"name": "id",
"value": "orgspringframework-7161"
},
{
"name": "user",
"value": "user"
},
{
"name": "ip",
"value": "127.0.0.1"
}
],
"severity": 0,
"timestamp": "2021-02-08T14:31:13.523Z"
}
],
"name": "open",
"started": "2021-02-08T14:31:00.662Z",
"stopped": "2021-02-08T14:31:14.855Z"
},
{
"events": [
{
"name": "rulesEvaluate",
"properties": [
{
"name": "id",
"value": "5e9e8e6f8d20a3"
},
{
"name": "rule",
"value": "no-traversal-paths-in-archive-file"
},
{
"name": "rule",
"value": "profile-target-matching-staging"
},
{
"name": "rule",
"value": "sbom-report"
},
{
"name": "rule",
"value": "checksum-staging"
},
{
"name": "rule",
"value": "javadoc-staging"
},
{
"name": "rule",
"value": "pom-staging"
},
{
"name": "rule",
"value": "signature-staging"
},
{
"name": "rule",
"value": "sources-staging"
}
],
"severity": 0,
"timestamp": "2021-02-08T14:31:37.327Z"
},
{
"name": "ruleEvaluate",
"properties": [
{
"name": "typeId",
"value": "no-traversal-paths-in-archive-file"
}
],
"severity": 0,
"timestamp": "2021-02-08T14:31:41.254Z"
},
{
"name": "rulePassed",
"properties": [
{
"name": "typeId",
"value": "no-traversal-paths-in-archive-file"
}
],
"severity": 0,
"timestamp": "2021-02-08T14:31:47.498Z"
},
{
"name": "ruleEvaluate",
"properties": [
{
"name": "typeId",
"value": "javadoc-staging"
}
],
"severity": 0,
"timestamp": "2021-02-08T14:31:53.438Z"
},
{
"name": "rulePassed",
"properties": [
{
"name": "typeId",
"value": "javadoc-staging"
}
],
"severity": 0,
"timestamp": "2021-02-08T14:31:54.623Z"
},
{
"name": "ruleEvaluate",
"properties": [
{
"name": "typeId",
"value": "pom-staging"
}
],
"severity": 0,
"timestamp": "2021-02-08T14:31:58.091Z"
},
{
"name": "ruleFailed",
"properties": [
{
"name": "typeId",
"value": "pom-staging"
},
{
"name": "failureMessage",
"value": "Invalid POM: /org/springframework/example/module-one/1.0.0/module-one-1.0.0.pom: Project name missing, Project description missing, Project URL missing, License information missing, SCM URL missing, Developer information missing"
},
{
"name": "failureMessage",
"value": "Invalid POM: /org/springframework/example/module-two/1.0.0/module-two-1.0.0.pom: Project name missing, Project description missing, Project URL missing, License information missing, SCM URL missing, Developer information missing"
},
{
"name": "failureMessage",
"value": "Invalid POM: /org/springframework/example/module-three/1.0.0/module-three-1.0.0.pom: Project name missing, Project description missing, Project URL missing, License information missing, SCM URL missing, Developer information missing"
}
],
"severity": 1,
"timestamp": "2021-02-08T14:31:59.403Z"
},
{
"name": "ruleEvaluate",
"properties": [
{
"name": "typeId",
"value": "profile-target-matching-staging"
}
],
"severity": 0,
"timestamp": "2021-02-08T14:32:05.322Z"
},
{
"name": "rulePassed",
"properties": [
{
"name": "typeId",
"value": "profile-target-matching-staging"
}
],
"severity": 0,
"timestamp": "2021-02-08T14:32:06.492Z"
},
{
"name": "ruleEvaluate",
"properties": [
{
"name": "typeId",
"value": "checksum-staging"
}
],
"severity": 0,
"timestamp": "2021-02-08T14:32:12.415Z"
},
{
"name": "rulePassed",
"properties": [
{
"name": "typeId",
"value": "checksum-staging"
}
],
"severity": 0,
"timestamp": "2021-02-08T14:32:13.568Z"
},
{
"name": "ruleEvaluate",
"properties": [
{
"name": "typeId",
"value": "signature-staging"
}
],
"severity": 0,
"timestamp": "2021-02-08T14:32:18.288Z"
},
{
"name": "ruleFailed",
"properties": [
{
"name": "typeId",
"value": "signature-staging"
},
{
"name": "failureMessage",
"value": "Missing Signature: '/org/springframework/example/module-one/1.0.0/module-one-1.0.0-javadoc.jar.asc' does not exist for 'module-one-1.0.0-javadoc.jar'."
},
{
"name": "failureMessage",
"value": "Missing Signature: '/org/springframework/example/module-one/1.0.0/module-one-1.0.0.jar.asc' does not exist for 'module-one-1.0.0.jar'."
},
{
"name": "failureMessage",
"value": "Missing Signature: '/org/springframework/example/module-one/1.0.0/module-one-1.0.0-sources.jar.asc' does not exist for 'module-one-1.0.0-sources.jar'."
},
{
"name": "failureMessage",
"value": "Missing Signature: '/org/springframework/example/module-one/1.0.0/module-one-1.0.0.module.asc' does not exist for 'module-one-1.0.0.module'."
},
{
"name": "failureMessage",
"value": "Missing Signature: '/org/springframework/example/module-one/1.0.0/module-one-1.0.0.pom.asc' does not exist for 'module-one-1.0.0.pom'."
},
{
"name": "failureMessage",
"value": "Missing Signature: '/org/springframework/example/module-two/1.0.0/module-two-1.0.0.module.asc' does not exist for 'module-two-1.0.0.module'."
},
{
"name": "failureMessage",
"value": "Missing Signature: '/org/springframework/example/module-two/1.0.0/module-two-1.0.0.pom.asc' does not exist for 'module-two-1.0.0.pom'."
},
{
"name": "failureMessage",
"value": "Missing Signature: '/org/springframework/example/module-two/1.0.0/module-two-1.0.0-sources.jar.asc' does not exist for 'module-two-1.0.0-sources.jar'."
},
{
"name": "failureMessage",
"value": "Missing Signature: '/org/springframework/example/module-two/1.0.0/module-two-1.0.0.jar.asc' does not exist for 'module-two-1.0.0.jar'."
},
{
"name": "failureMessage",
"value": "Missing Signature: '/org/springframework/example/module-two/1.0.0/module-two-1.0.0-javadoc.jar.asc' does not exist for 'module-two-1.0.0-javadoc.jar'."
},
{
"name": "failureMessage",
"value": "Missing Signature: '/org/springframework/example/module-three/1.0.0/module-three-1.0.0.module.asc' does not exist for 'module-three-1.0.0.module'."
},
{
"name": "failureMessage",
"value": "Missing Signature: '/org/springframework/example/module-three/1.0.0/module-three-1.0.0-javadoc.jar.asc' does not exist for 'module-three-1.0.0-javadoc.jar'."
},
{
"name": "failureMessage",
"value": "Missing Signature: '/org/springframework/example/module-three/1.0.0/module-three-1.0.0-sources.jar.asc' does not exist for 'module-three-1.0.0-sources.jar'."
},
{
"name": "failureMessage",
"value": "Missing Signature: '/org/springframework/example/module-three/1.0.0/module-three-1.0.0.jar.asc' does not exist for 'module-three-1.0.0.jar'."
},
{
"name": "failureMessage",
"value": "Missing Signature: '/org/springframework/example/module-three/1.0.0/module-three-1.0.0.pom.asc' does not exist for 'module-three-1.0.0.pom'."
}
],
"severity": 1,
"timestamp": "2021-02-08T14:32:19.443Z"
},
{
"name": "ruleEvaluate",
"properties": [
{
"name": "typeId",
"value": "sources-staging"
}
],
"severity": 0,
"timestamp": "2021-02-08T14:32:24.175Z"
},
{
"name": "rulePassed",
"properties": [
{
"name": "typeId",
"value": "sources-staging"
}
],
"severity": 0,
"timestamp": "2021-02-08T14:32:28.940Z"
},
{
"name": "ruleEvaluate",
"properties": [
{
"name": "typeId",
"value": "sbom-report"
}
],
"severity": 0,
"timestamp": "2021-02-08T14:32:34.906Z"
},
{
"name": "rulePassed",
"properties": [
{
"name": "typeId",
"value": "sbom-report"
},
{
"name": "successMessage",
"value": "Successfully requested SBOM report"
}
],
"severity": 0,
"timestamp": "2021-02-08T14:32:36.520Z"
},
{
"name": "rulesFailed",
"properties": [
{
"name": "id",
"value": "5e9e8e6f8d20a3"
},
{
"name": "failureCount",
"value": "2"
}
],
"severity": 1,
"timestamp": "2021-02-08T14:32:42.068Z"
},
{
"name": "repositoryCloseFailed",
"properties": [
{
"name": "id",
"value": "orgspringframework-7161"
},
{
"name": "cause",
"value": "com.sonatype.nexus.staging.StagingRulesFailedException: One or more rules have failed"
}
],
"severity": 1,
"timestamp": "2021-02-08T14:32:43.218Z"
}
],
"name": "close",
"started": "2021-02-08T14:31:34.943Z",
"startedByIpAddress": "127.0.0.1",
"startedByUserId": "user",
"stopped": "2021-02-08T14:32:47.138Z"
}
]

@ -0,0 +1,35 @@
{
"buildInfo": {
"version": "1.0.1",
"name": "example",
"number": "example-build-1",
"started": "2019-09-10T12:18:05.430+0000",
"durationMillis": 0,
"artifactoryPrincipal": "user",
"url": "https://my-ci.com",
"modules": [
{
"id": "org.example.demo:demo:2.2.0",
"artifacts": [
{
"type": "jar",
"sha1": "ayyyya9151a22cb3145538e523dbbaaaaaaaa",
"sha256": "aaaaaaaaa85f5c5093721f3ed0edda8ff8290yyyyyyyyyy",
"md5": "aaaaaacddea1724b0b69d8yyyyyyy",
"name": "demo-2.2.0.jar"
}
]
}
],
"statuses": [
{
"status": "staged",
"repository": "libs-release-local",
"timestamp": "2019-09-10T12:42:24.716+0000",
"user": "user",
"timestampDate": 1568119344716
}
]
},
"uri": "https://my-artifactory-repo.com/api/build/example/example-build-1"
}

@ -0,0 +1,11 @@
-----BEGIN PGP SIGNATURE-----
iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1oACgkQmix6mORX
xT3qtgf8CeDvxzi7lPghlrZtmYTTjaic4FZsGRWTPky3H24i4wSRDhG0L5sj4uPK
eLLlITr5a9j26UCas9HRSthiC8+EgIMAhSN0X482SQhUZHAW67ErIvaHlwL+ixMD
0T5pmsW8PKN3lV1TFMhNYSEC2GRG/4GF+3yQA8LR+BgeEu/E5nmysIH8vuQMkOD6
3pKA8VKNBml591j6UTqxoHtPX+rThaziz3Hy3+ekf5iWslllTTGPd2SWqTvnj2Ae
GvRzsbli+FEM0Aj/v8jUQnQzOz891QSvWR+fMfCqZimiJMc+GBzJ9umbcyQsB5tY
e26mAoYd9KEpGXMKN4biHbJZNp1GGw==
=x/MY
-----END PGP SIGNATURE-----

@ -0,0 +1,11 @@
-----BEGIN PGP SIGNATURE-----
iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1oACgkQmix6mORX
xT1PSwf+InTdlh+BVVy3RFszEL5vN7waSPYMImXhgd5EZ1d4Lxd6EeOwtNgWNKpG
E+Ps/Kw0jZDvoD49WJlcUjzDHBNHcE7C/L3GAWHV6WwklhtQaJ4EegsynWdSXz6k
fqJY6r58aGKGjpKPutRWAjvfcdC170+ZRsc2oi9xrAgHCpvXzTjq4+O9Ah0t5jwW
jcZ/Xubcw4vjsw774OucHbtwGsvRN5SDJ3IONOH8WCwhUP5vEEKvA6MYX0KGoTdS
3wTCyZTzU3qtTWxcbTCpiJIWbYwRR7TzLB/uydWHlAMzuz6coIiBpYsGiO6wkmfg
W+QvcE7wyW2jtb22pCImLyObyZ21VA==
=VjDv
-----END PGP SIGNATURE-----

@ -0,0 +1,11 @@
-----BEGIN PGP SIGNATURE-----
iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1oACgkQmix6mORX
xT2HQgf+MTUEnwzXK4zi76VI7C5cchGan26lIA2Ebq4vtYGKDqNSISOAAdWs9+nT
U6ZA6OIFo5qdeD6F/45s91IoDbxbhMDMFEsSijKASqiuZN5TZM1U2h2kWFAl/sEl
EI1RTygn+xDw/ah4V3/duuMFC+jRgvJ/LgemIF4KBvECWaTQKNu0fu5d4dPXMpp+
jrxMEZPQZsivpOvklzV8O7wAkf/ZQhJdcB2m8uOfSPlJ91a4EEtXF9/GzzkXUi1P
bzt4NsmOag3227B3mO1Bc6yZdDBNu8wQ9apiJVCpqsxB9Dz0PCL4dHNa1u9g6Xo6
ElRgneV4HZp+LB125VoNabKuNH00bw==
=2yDl
-----END PGP SIGNATURE-----

@ -0,0 +1,11 @@
-----BEGIN PGP SIGNATURE-----
iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1oACgkQmix6mORX
xT0ScQf7Bip+quFq1CzDCTDxUhdTTOIpQcCfMKo1Jegpa2Hlm63XuK+6zVI9u6S+
dBXPdYeneMOqMPQUAaw3M06ZqohaDOt8Ti1EiQFGXCdOvbonTA52Lrd4EEZxwNnK
BdPuIh/8qCfozm5KbZe1bFyGVRAdNyf27KvzHgfBTirLtI+3MiOdL4bvNZbWRPfh
J84Ko+Ena8jPFgyz6nJv2Q2U/V3dCooLJAXs2vEG6owwk5J9zvSysWpHaJbXas5v
KXO9TOBBjf3+vxb1WVQa8ZYUU3+FIFes0RFVgOWghJXIooOcWrwOV2Q8z9qWXwoK
mMZ2oLS+z/7clXibK45KeRUeCX5DvQ==
=5oO1
-----END PGP SIGNATURE-----

@ -0,0 +1 @@
2730ac0859e1c2f450d54647c29ece43d095eb834e2130d4949dd1151317d013c072fa8f96f5f4b49836eff7c19a2eeeb5ca7483e5dac24c368bbcd522c27a00

@ -0,0 +1,11 @@
-----BEGIN PGP SIGNATURE-----
iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1oACgkQmix6mORX
xT3d+AgAwQvlwnKQLLuiZADGL+I922/YG317N2Re1EjC6WlMRZUKXH54fckRTyPm
4ZLyxVHy8LlUD2Q10g69opb7HRd/tV0miBJhn5OU1wIM3hqTgxNp9EFckK4md45k
osnhQJNDsFToxJL8zPP+KRs/aWPZs+FrRcH6k26lwLl2gTfyBDsaU11HFRVEN9yi
X41obVyKiVNlc9efSSvlLtRBSVt0VhAFhck+3t61H6D9H09QxaDGAqmduDua3Tg3
t5eqURuDfv3TfSztYgK3JBmG/6gVMsZodCgyC+8rhDDs6vSoDG30apx5Leg2rPbj
xuk2wi/WNzc94IgY9tVS3tAfT2k6yQ==
=6+Cv
-----END PGP SIGNATURE-----

@ -0,0 +1,11 @@
-----BEGIN PGP SIGNATURE-----
iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1oACgkQmix6mORX
xT3qtgf8CeDvxzi7lPghlrZtmYTTjaic4FZsGRWTPky3H24i4wSRDhG0L5sj4uPK
eLLlITr5a9j26UCas9HRSthiC8+EgIMAhSN0X482SQhUZHAW67ErIvaHlwL+ixMD
0T5pmsW8PKN3lV1TFMhNYSEC2GRG/4GF+3yQA8LR+BgeEu/E5nmysIH8vuQMkOD6
3pKA8VKNBml591j6UTqxoHtPX+rThaziz3Hy3+ekf5iWslllTTGPd2SWqTvnj2Ae
GvRzsbli+FEM0Aj/v8jUQnQzOz891QSvWR+fMfCqZimiJMc+GBzJ9umbcyQsB5tY
e26mAoYd9KEpGXMKN4biHbJZNp1GGw==
=x/MY
-----END PGP SIGNATURE-----

@ -0,0 +1,11 @@
-----BEGIN PGP SIGNATURE-----
iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1oACgkQmix6mORX
xT1PSwf+InTdlh+BVVy3RFszEL5vN7waSPYMImXhgd5EZ1d4Lxd6EeOwtNgWNKpG
E+Ps/Kw0jZDvoD49WJlcUjzDHBNHcE7C/L3GAWHV6WwklhtQaJ4EegsynWdSXz6k
fqJY6r58aGKGjpKPutRWAjvfcdC170+ZRsc2oi9xrAgHCpvXzTjq4+O9Ah0t5jwW
jcZ/Xubcw4vjsw774OucHbtwGsvRN5SDJ3IONOH8WCwhUP5vEEKvA6MYX0KGoTdS
3wTCyZTzU3qtTWxcbTCpiJIWbYwRR7TzLB/uydWHlAMzuz6coIiBpYsGiO6wkmfg
W+QvcE7wyW2jtb22pCImLyObyZ21VA==
=VjDv
-----END PGP SIGNATURE-----

@ -0,0 +1,11 @@
-----BEGIN PGP SIGNATURE-----
iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1oACgkQmix6mORX
xT2HQgf+MTUEnwzXK4zi76VI7C5cchGan26lIA2Ebq4vtYGKDqNSISOAAdWs9+nT
U6ZA6OIFo5qdeD6F/45s91IoDbxbhMDMFEsSijKASqiuZN5TZM1U2h2kWFAl/sEl
EI1RTygn+xDw/ah4V3/duuMFC+jRgvJ/LgemIF4KBvECWaTQKNu0fu5d4dPXMpp+
jrxMEZPQZsivpOvklzV8O7wAkf/ZQhJdcB2m8uOfSPlJ91a4EEtXF9/GzzkXUi1P
bzt4NsmOag3227B3mO1Bc6yZdDBNu8wQ9apiJVCpqsxB9Dz0PCL4dHNa1u9g6Xo6
ElRgneV4HZp+LB125VoNabKuNH00bw==
=2yDl
-----END PGP SIGNATURE-----

@ -0,0 +1,11 @@
-----BEGIN PGP SIGNATURE-----
iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1oACgkQmix6mORX
xT0ScQf7Bip+quFq1CzDCTDxUhdTTOIpQcCfMKo1Jegpa2Hlm63XuK+6zVI9u6S+
dBXPdYeneMOqMPQUAaw3M06ZqohaDOt8Ti1EiQFGXCdOvbonTA52Lrd4EEZxwNnK
BdPuIh/8qCfozm5KbZe1bFyGVRAdNyf27KvzHgfBTirLtI+3MiOdL4bvNZbWRPfh
J84Ko+Ena8jPFgyz6nJv2Q2U/V3dCooLJAXs2vEG6owwk5J9zvSysWpHaJbXas5v
KXO9TOBBjf3+vxb1WVQa8ZYUU3+FIFes0RFVgOWghJXIooOcWrwOV2Q8z9qWXwoK
mMZ2oLS+z/7clXibK45KeRUeCX5DvQ==
=5oO1
-----END PGP SIGNATURE-----

@ -0,0 +1 @@
2730ac0859e1c2f450d54647c29ece43d095eb834e2130d4949dd1151317d013c072fa8f96f5f4b49836eff7c19a2eeeb5ca7483e5dac24c368bbcd522c27a00

@ -0,0 +1,11 @@
-----BEGIN PGP SIGNATURE-----
iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1oACgkQmix6mORX
xT3d+AgAwQvlwnKQLLuiZADGL+I922/YG317N2Re1EjC6WlMRZUKXH54fckRTyPm
4ZLyxVHy8LlUD2Q10g69opb7HRd/tV0miBJhn5OU1wIM3hqTgxNp9EFckK4md45k
osnhQJNDsFToxJL8zPP+KRs/aWPZs+FrRcH6k26lwLl2gTfyBDsaU11HFRVEN9yi
X41obVyKiVNlc9efSSvlLtRBSVt0VhAFhck+3t61H6D9H09QxaDGAqmduDua3Tg3
t5eqURuDfv3TfSztYgK3JBmG/6gVMsZodCgyC+8rhDDs6vSoDG30apx5Leg2rPbj
xuk2wi/WNzc94IgY9tVS3tAfT2k6yQ==
=6+Cv
-----END PGP SIGNATURE-----

@ -0,0 +1,11 @@
-----BEGIN PGP SIGNATURE-----
iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1oACgkQmix6mORX
xT3qtgf8CeDvxzi7lPghlrZtmYTTjaic4FZsGRWTPky3H24i4wSRDhG0L5sj4uPK
eLLlITr5a9j26UCas9HRSthiC8+EgIMAhSN0X482SQhUZHAW67ErIvaHlwL+ixMD
0T5pmsW8PKN3lV1TFMhNYSEC2GRG/4GF+3yQA8LR+BgeEu/E5nmysIH8vuQMkOD6
3pKA8VKNBml591j6UTqxoHtPX+rThaziz3Hy3+ekf5iWslllTTGPd2SWqTvnj2Ae
GvRzsbli+FEM0Aj/v8jUQnQzOz891QSvWR+fMfCqZimiJMc+GBzJ9umbcyQsB5tY
e26mAoYd9KEpGXMKN4biHbJZNp1GGw==
=x/MY
-----END PGP SIGNATURE-----

@ -0,0 +1,11 @@
-----BEGIN PGP SIGNATURE-----
iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1oACgkQmix6mORX
xT1PSwf+InTdlh+BVVy3RFszEL5vN7waSPYMImXhgd5EZ1d4Lxd6EeOwtNgWNKpG
E+Ps/Kw0jZDvoD49WJlcUjzDHBNHcE7C/L3GAWHV6WwklhtQaJ4EegsynWdSXz6k
fqJY6r58aGKGjpKPutRWAjvfcdC170+ZRsc2oi9xrAgHCpvXzTjq4+O9Ah0t5jwW
jcZ/Xubcw4vjsw774OucHbtwGsvRN5SDJ3IONOH8WCwhUP5vEEKvA6MYX0KGoTdS
3wTCyZTzU3qtTWxcbTCpiJIWbYwRR7TzLB/uydWHlAMzuz6coIiBpYsGiO6wkmfg
W+QvcE7wyW2jtb22pCImLyObyZ21VA==
=VjDv
-----END PGP SIGNATURE-----

@ -0,0 +1,11 @@
-----BEGIN PGP SIGNATURE-----
iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1oACgkQmix6mORX
xT2HQgf+MTUEnwzXK4zi76VI7C5cchGan26lIA2Ebq4vtYGKDqNSISOAAdWs9+nT
U6ZA6OIFo5qdeD6F/45s91IoDbxbhMDMFEsSijKASqiuZN5TZM1U2h2kWFAl/sEl
EI1RTygn+xDw/ah4V3/duuMFC+jRgvJ/LgemIF4KBvECWaTQKNu0fu5d4dPXMpp+
jrxMEZPQZsivpOvklzV8O7wAkf/ZQhJdcB2m8uOfSPlJ91a4EEtXF9/GzzkXUi1P
bzt4NsmOag3227B3mO1Bc6yZdDBNu8wQ9apiJVCpqsxB9Dz0PCL4dHNa1u9g6Xo6
ElRgneV4HZp+LB125VoNabKuNH00bw==
=2yDl
-----END PGP SIGNATURE-----

@ -0,0 +1,11 @@
-----BEGIN PGP SIGNATURE-----
iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1oACgkQmix6mORX
xT0ScQf7Bip+quFq1CzDCTDxUhdTTOIpQcCfMKo1Jegpa2Hlm63XuK+6zVI9u6S+
dBXPdYeneMOqMPQUAaw3M06ZqohaDOt8Ti1EiQFGXCdOvbonTA52Lrd4EEZxwNnK
BdPuIh/8qCfozm5KbZe1bFyGVRAdNyf27KvzHgfBTirLtI+3MiOdL4bvNZbWRPfh
J84Ko+Ena8jPFgyz6nJv2Q2U/V3dCooLJAXs2vEG6owwk5J9zvSysWpHaJbXas5v
KXO9TOBBjf3+vxb1WVQa8ZYUU3+FIFes0RFVgOWghJXIooOcWrwOV2Q8z9qWXwoK
mMZ2oLS+z/7clXibK45KeRUeCX5DvQ==
=5oO1
-----END PGP SIGNATURE-----

@ -0,0 +1 @@
2730ac0859e1c2f450d54647c29ece43d095eb834e2130d4949dd1151317d013c072fa8f96f5f4b49836eff7c19a2eeeb5ca7483e5dac24c368bbcd522c27a00

@ -0,0 +1,11 @@
-----BEGIN PGP SIGNATURE-----
iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1oACgkQmix6mORX
xT3d+AgAwQvlwnKQLLuiZADGL+I922/YG317N2Re1EjC6WlMRZUKXH54fckRTyPm
4ZLyxVHy8LlUD2Q10g69opb7HRd/tV0miBJhn5OU1wIM3hqTgxNp9EFckK4md45k
osnhQJNDsFToxJL8zPP+KRs/aWPZs+FrRcH6k26lwLl2gTfyBDsaU11HFRVEN9yi
X41obVyKiVNlc9efSSvlLtRBSVt0VhAFhck+3t61H6D9H09QxaDGAqmduDua3Tg3
t5eqURuDfv3TfSztYgK3JBmG/6gVMsZodCgyC+8rhDDs6vSoDG30apx5Leg2rPbj
xuk2wi/WNzc94IgY9tVS3tAfT2k6yQ==
=6+Cv
-----END PGP SIGNATURE-----

@ -0,0 +1,101 @@
{
"formatVersion": "1.1",
"component": {
"group": "org.springframework.example",
"module": "module-one",
"version": "1.0.0",
"attributes": {
"org.gradle.status": "release"
}
},
"createdBy": {
"gradle": {
"version": "6.5.1",
"buildId": "mvqepqsdqjcahjl7cii6b6ucoe"
}
},
"variants": [
{
"name": "apiElements",
"attributes": {
"org.gradle.category": "library",
"org.gradle.dependency.bundling": "external",
"org.gradle.jvm.version": 8,
"org.gradle.libraryelements": "jar",
"org.gradle.usage": "java-api"
},
"files": [
{
"name": "module-one-1.0.0.jar",
"url": "module-one-1.0.0.jar",
"size": 261,
"sha512": "2730ac0859e1c2f450d54647c29ece43d095eb834e2130d4949dd1151317d013c072fa8f96f5f4b49836eff7c19a2eeeb5ca7483e5dac24c368bbcd522c27a00",
"sha256": "10ce8a82f7e53c67f2af8d4dc4b8cdb4d0630d0e1d21818da4d5b3ca2de08385",
"sha1": "8992b17455ce660da9c5fe47226b7ded9e872637",
"md5": "e84da489be91de821c95d41b8f0e0a0a"
}
]
},
{
"name": "runtimeElements",
"attributes": {
"org.gradle.category": "library",
"org.gradle.dependency.bundling": "external",
"org.gradle.jvm.version": 8,
"org.gradle.libraryelements": "jar",
"org.gradle.usage": "java-runtime"
},
"files": [
{
"name": "module-one-1.0.0.jar",
"url": "module-one-1.0.0.jar",
"size": 261,
"sha512": "2730ac0859e1c2f450d54647c29ece43d095eb834e2130d4949dd1151317d013c072fa8f96f5f4b49836eff7c19a2eeeb5ca7483e5dac24c368bbcd522c27a00",
"sha256": "10ce8a82f7e53c67f2af8d4dc4b8cdb4d0630d0e1d21818da4d5b3ca2de08385",
"sha1": "8992b17455ce660da9c5fe47226b7ded9e872637",
"md5": "e84da489be91de821c95d41b8f0e0a0a"
}
]
},
{
"name": "javadocElements",
"attributes": {
"org.gradle.category": "documentation",
"org.gradle.dependency.bundling": "external",
"org.gradle.docstype": "javadoc",
"org.gradle.usage": "java-runtime"
},
"files": [
{
"name": "module-one-1.0.0-javadoc.jar",
"url": "module-one-1.0.0-javadoc.jar",
"size": 261,
"sha512": "2730ac0859e1c2f450d54647c29ece43d095eb834e2130d4949dd1151317d013c072fa8f96f5f4b49836eff7c19a2eeeb5ca7483e5dac24c368bbcd522c27a00",
"sha256": "10ce8a82f7e53c67f2af8d4dc4b8cdb4d0630d0e1d21818da4d5b3ca2de08385",
"sha1": "8992b17455ce660da9c5fe47226b7ded9e872637",
"md5": "e84da489be91de821c95d41b8f0e0a0a"
}
]
},
{
"name": "sourcesElements",
"attributes": {
"org.gradle.category": "documentation",
"org.gradle.dependency.bundling": "external",
"org.gradle.docstype": "sources",
"org.gradle.usage": "java-runtime"
},
"files": [
{
"name": "module-one-1.0.0-sources.jar",
"url": "module-one-1.0.0-sources.jar",
"size": 261,
"sha512": "2730ac0859e1c2f450d54647c29ece43d095eb834e2130d4949dd1151317d013c072fa8f96f5f4b49836eff7c19a2eeeb5ca7483e5dac24c368bbcd522c27a00",
"sha256": "10ce8a82f7e53c67f2af8d4dc4b8cdb4d0630d0e1d21818da4d5b3ca2de08385",
"sha1": "8992b17455ce660da9c5fe47226b7ded9e872637",
"md5": "e84da489be91de821c95d41b8f0e0a0a"
}
]
}
]
}

@ -0,0 +1,11 @@
-----BEGIN PGP SIGNATURE-----
iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1oACgkQmix6mORX
xT1HBQf/fCBHR+fpZjkcgonkAVWcGvRx5kRHlsCISs64XMw90++DTawoKxr9/TvY
fltQlq/xaf+2O2Xzh9HIymtZBeKp7a4fWQ2AHf/ygkGyIKvy8h+mu3MGDdmHZeA4
fn9FGjaE0a/wYJmCEHJ1qJ4GaNq47gzRTu76jzZNafnNRlq1rlyVu2txnlks6xDr
oE8EnRT86Y67Ku8YArjkhZSHhf/tzSSwdTAgBinh6eba5tW5ueRXfsheqgtpJMov
hiDIVxuAlJoHy2cQ8L9+8geg0OSXLwQ9BXrBsDCLvrDauU735/Hv/NGrWE95kemw
Ay9jCXhXFWKkzCw2ps3QHTTpTK4aVw==
=1QME
-----END PGP SIGNATURE-----

@ -0,0 +1,11 @@
-----BEGIN PGP SIGNATURE-----
iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1oACgkQmix6mORX
xT1SLQgApB6OWW9cgtaofOu3HwgsVxaxLYPsDf057m2O5pI6uV5Ikyt97B1txjTB
9EXcy4gsfu7rxwgRHIEPCQkQKhhZioscT1SPFN0yopCwsJEvxrJE018ojyaIem/L
KVcbtiBVMj3GZCbS0DHpwZNx2u7yblyBqUGhCMKLkYqVL7nUHJKtECECs5jbJnb9
xXGFe0xlZ/IbkHv5QXyStgUYCah7ayWQDvjN7UJrpJL1lmTD0rjWLilkeKsVu3/k
11cZb5YdOmrL9a+8ql1jXPkma3HPjoIPRC5LB2BnloduwEPsiiLGG7Cs8UFEJNjQ
m5w+l4dDd03y5ioaW8fI/meAKpBm4g==
=gwLM
-----END PGP SIGNATURE-----

@ -0,0 +1,11 @@
-----BEGIN PGP SIGNATURE-----
iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1oACgkQmix6mORX
xT2y5AgAlI4H5hwDIgVmXtRq/ri7kxEJnC9L9FOv8aE9YasHAruaU1YR5m17Jncl
4guJHc+gSd3BiSx1rsI6PNxLACabw4Vy56eCRpmiFWeIkoCETBUk8AN25Q/1tzgw
hHmIRgOkF9PzSBWDTUNsyx/7E9P2QSiJOkMAGGuMKGDpYTR9zmaluzwfY+BI/VoW
BbZpdzt02OGQosWmA7DlwkXUwip6iBjga79suUFIsyH0hmRW2q/nCeJ04ttzXUog
NTNkpEwMYpZAzQXE7ks7WJJlAPkVYPWy/j5YCV7xTFb9I/56ux+/wRUaGU5fumSR
lr3PNoYNToC/4GLX6Kc2OH0e1LXNTQ==
=s02D
-----END PGP SIGNATURE-----

@ -0,0 +1,11 @@
-----BEGIN PGP SIGNATURE-----
iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1oACgkQmix6mORX
xT1/vwgAhUTLKjxmry4W3cVdfX/D/vxDTLAp5OxwJy36CZmJwsVuN9TLjPo4tRqq
woiopR2oSTaJqld2pe98WlIeDJJRe4ta1Uwvg7k4Sf6YaZXm01Wufk4a835sFUwY
BTWmnFYX0+dp5mLyXZmZjrAr5Q2bowRuqZd2DAYiNY/E5MH2T7OAJE2hCOHUpCaB
JVeP7HcbaGYR3NX/mLq0t8+xjTPXQk/OHijuusuLQxfLZvZiaikDoOHUD6l0dlRw
xcLTghG5+jd1q7noKAbUVgoEOshstfomCHZpPMj11c7KIuG1+3wRMdm+F67lkcJ5
eDW2fmF+6LYr+WlEi33rDIyTk3GhlQ==
=mHUe
-----END PGP SIGNATURE-----

@ -0,0 +1 @@
29b1bc06a150e4764826e35e2d2541933b0583ce823f5b00c02effad9f37f02f0d2eef1c81214d69eaf74220e1f77332c5e6a91eb413a3022b5a8a1d7914c4c3

@ -0,0 +1,11 @@
-----BEGIN PGP SIGNATURE-----
iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1oACgkQmix6mORX
xT0QLAf/ffTpTfH4IebklGJIKZC8ZjRt4CgwpR431qNeWkY25cHmWFj48x2u9dmS
ZpxN572d3PPjcMigT/9wM05omiU+4DHxGgHq/Xj6GXN1DNaENcu7uoye96thjKPv
jz98tPIRMC9hYr3m/K1CJ3+ZG0++7JorCZRpodH/MhklRWXOvNszs81VWtgvMnpd
h9r0PuoaYBl6bIl19o7E3JJU6dKgwfre4b+a1RSYI+A8bmJOKMgHytAKi+804r0P
4R2WuQT4q+dSmkMtgp65vJ9giv/xuFrd1bT4n+qcDkwE8pTcWvsB4w1RkDOKs4fK
/ta5xBQ1hiKAd6nJffke1b0MBrZOrA==
=ZMpE
-----END PGP SIGNATURE-----

@ -0,0 +1,49 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd" xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<!-- This module was also published with a richer model, Gradle metadata, -->
<!-- which should be used instead. Do not delete the following line which -->
<!-- is to indicate to Gradle or any Gradle module metadata file consumer -->
<!-- that they should prefer consuming it instead. -->
<!-- do_not_remove: published-with-gradle-metadata -->
<modelVersion>4.0.0</modelVersion>
<groupId>org.springframework.example</groupId>
<artifactId>module-one</artifactId>
<version>1.0.0</version>
<name>module-one</name>
<description>Example module</description>
<url>https://spring.io/projects/spring-boot</url>
<organization>
<name>Pivotal Software, Inc.</name>
<url>https://spring.io</url>
</organization>
<licenses>
<license>
<name>Apache License, Version 2.0</name>
<url>https://www.apache.org/licenses/LICENSE-2.0</url>
</license>
</licenses>
<developers>
<developer>
<name>Pivotal</name>
<email>info@pivotal.io</email>
<organization>Pivotal Software, Inc.</organization>
<organizationUrl>https://www.spring.io</organizationUrl>
</developer>
</developers>
<scm>
<connection>
scm:git:git://github.com/spring-projects/spring-boot.git
</connection>
<developerConnection>
scm:git:ssh://git@github.com/spring-projects/spring-boot.git
</developerConnection>
<url>https://github.com/spring-projects/spring-boot</url>
</scm>
<issueManagement>
<system>GitHub</system>
<url>
https://github.com/spring-projects/spring-boot/issues
</url>
</issueManagement>
</project>

@ -0,0 +1,11 @@
-----BEGIN PGP SIGNATURE-----
iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1oACgkQmix6mORX
xT04rwgAwJHic8GGHFZ+UAJYLW/OxJOVyd0ebx4yT5zAyTjyvxnrlKmKZ6GP/NhZ
htJQnZez85lUKA0TsMvl/6H2iEhKOns6HgqY3PLFkKNRKOq601phtD9HCkxDibWB
UDT01I0q2xNOljD03lhfytefnSnZ96AaySol2v5DBIZsOKWGir0/8KJCpEQJHjCF
TwNk8lNF3moGlO4zUfoBbkSZ+J0J8Bq5QI3nIAWFYxHcrZ2YGsAZd48kux8x2V3C
c6QsYEonmztqxop76a7K8Gv+MDmo/u/vqM8z5C63/WpOoDtRG+F5vtPkhCrR6M5f
ygubQUy5TL+dWdHE8zgA2O9hZuoHEg==
=bkxG
-----END PGP SIGNATURE-----

@ -0,0 +1,11 @@
-----BEGIN PGP SIGNATURE-----
iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1oACgkQmix6mORX
xT0XEAf+O9a/29MIWBtj1oLxIT1LLdzTU68qt5+qW+58SNQmMxu0MaESW4GZOc3p
mTV0EJyxUkCLJyoqOY4/GhqBAm33mMZSY8BQtvUZPYxpbJwBo+pE8YfnH3n1v20P
4pS4oJKekXAhTqShpx5oFjCK4J3chaz+Xc8Ldm1DXakCRc1bc/YYZ+87sy2z+PXk
PmN3KPcc/XjH4GPjmVUR8vR1TGUjUMQGvbAdrgkjFyaCGNvyreuHLsAFWrFFbIOn
/mB++enkXhmjWbiyvmvWQvtU0QFA4sRGYww0Lup1GRQ+00IqHF1QRMskqujAwmok
+TuB3Zc9WuAERPre+Qr1DEevClNwAQ==
=3beu
-----END PGP SIGNATURE-----

@ -0,0 +1,11 @@
-----BEGIN PGP SIGNATURE-----
iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1oACgkQmix6mORX
xT2aVAf+MQhSBr1jzcQE5mo1bMOXa4owaRr+dRir5Q4Gr7Fz4NuGoQEIzoL7XP5r
0zIjebzworxCaw+JNyaIxniHNBeK3sPHTLeW8bCrdJLkhE9RtGdEHLyPYXwPuFin
xVw3VQHWiA0uPM+JaekgdPDtK5wGFQ/AK3pc6vR108oT0kV4zQEqgRnvLqV9Q5zZ
UPHBi5kypu1BmCW4upYL1dmjASWPn9Q8cNpHcX/NJPNJ9zW0yxAAtq4wLfh7PQml
3EaHEYllsf8v1vMv00+zZNhc6O4BBP1qrRiaYHDAJhJjn6ctV9GFhJ2Ttxh/NmSy
H679tlC2PeRjGMi8bOHBshcikn5KUw==
=4aJI
-----END PGP SIGNATURE-----

@ -0,0 +1,11 @@
-----BEGIN PGP SIGNATURE-----
iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1oACgkQmix6mORX
xT0nDQgAlfchq7/W/wubx3IR3tQs0tKiix3nZIc97zuH6sR8+r+CJe78wbmSE9Oo
/z96wfzeZYNIKh2v+dBLHF7OfcPGBE7tiX07jfCa6KzjjY3hFBhW+muMP/aBRb+4
itSs6F3lkZOPW2+hpSdFQ6U8Rm81cAlZv7Zk2XswwTQkJo8GcNL1w/5wAVpNK0yG
VinZr8YRMFs6OYQxLqGSypDLAmv9rOaJ7aCdaKnQwYES65kC7tbe0SRZGQoDe8n4
XLzpvC8rM9MXZDEN4qI+ZAANOJNVsXUmDZLDSe4ak48u/cTOokY8I6bR2k/XOhbu
L+D4W7oKAE9HmzlTMusosyjNOBQAmQ==
=Wjji
-----END PGP SIGNATURE-----

@ -0,0 +1 @@
05bd8fd394a15b9dcc1bfaece0a63b0fdc2c3625a7e0aa5230fd3b5b75a8f8934a0af550b44437aa1486909058e84703e63fdec6f637d639d565b55bdaf1fa6c

@ -0,0 +1,11 @@
-----BEGIN PGP SIGNATURE-----
iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1oACgkQmix6mORX
xT19rwf/a6sZxSDNTxN72VvsrKsHq+wMes5UUcQ+L7e5QLjaCTx2ayW2FdHMBaNi
IDBBE9kxnxa/S6G6nSRARUjXowsEYZGUNLLvUjNZ4Z3g2R9XyGPaz3Ky9yWpRm36
E0lFqf8aaCLpzwV2z7cfeVNYsd2gnHakphK/UiZzXFz+GYzqby/0m5Kk8Zs7rK6V
/ji0bYWUi8t1jli8MfTHQtM8EUHG0nXRfEKilyoYkO3UsTEh/UN1VRpJ5DgcRC8L
Zbd2zPnV15MPUzZvz3kkycUulQdhOqTDjUod9P/WoASwjDuKCG2/kquwOvnoHXJ9
9Ju+ca0s9y0jbotIygYxJXZVev3EiA==
=oWIp
-----END PGP SIGNATURE-----

@ -0,0 +1,11 @@
-----BEGIN PGP SIGNATURE-----
iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1oACgkQmix6mORX
xT3qtgf8CeDvxzi7lPghlrZtmYTTjaic4FZsGRWTPky3H24i4wSRDhG0L5sj4uPK
eLLlITr5a9j26UCas9HRSthiC8+EgIMAhSN0X482SQhUZHAW67ErIvaHlwL+ixMD
0T5pmsW8PKN3lV1TFMhNYSEC2GRG/4GF+3yQA8LR+BgeEu/E5nmysIH8vuQMkOD6
3pKA8VKNBml591j6UTqxoHtPX+rThaziz3Hy3+ekf5iWslllTTGPd2SWqTvnj2Ae
GvRzsbli+FEM0Aj/v8jUQnQzOz891QSvWR+fMfCqZimiJMc+GBzJ9umbcyQsB5tY
e26mAoYd9KEpGXMKN4biHbJZNp1GGw==
=x/MY
-----END PGP SIGNATURE-----

@ -0,0 +1,11 @@
-----BEGIN PGP SIGNATURE-----
iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1oACgkQmix6mORX
xT1PSwf+InTdlh+BVVy3RFszEL5vN7waSPYMImXhgd5EZ1d4Lxd6EeOwtNgWNKpG
E+Ps/Kw0jZDvoD49WJlcUjzDHBNHcE7C/L3GAWHV6WwklhtQaJ4EegsynWdSXz6k
fqJY6r58aGKGjpKPutRWAjvfcdC170+ZRsc2oi9xrAgHCpvXzTjq4+O9Ah0t5jwW
jcZ/Xubcw4vjsw774OucHbtwGsvRN5SDJ3IONOH8WCwhUP5vEEKvA6MYX0KGoTdS
3wTCyZTzU3qtTWxcbTCpiJIWbYwRR7TzLB/uydWHlAMzuz6coIiBpYsGiO6wkmfg
W+QvcE7wyW2jtb22pCImLyObyZ21VA==
=VjDv
-----END PGP SIGNATURE-----

@ -0,0 +1,11 @@
-----BEGIN PGP SIGNATURE-----
iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1oACgkQmix6mORX
xT2HQgf+MTUEnwzXK4zi76VI7C5cchGan26lIA2Ebq4vtYGKDqNSISOAAdWs9+nT
U6ZA6OIFo5qdeD6F/45s91IoDbxbhMDMFEsSijKASqiuZN5TZM1U2h2kWFAl/sEl
EI1RTygn+xDw/ah4V3/duuMFC+jRgvJ/LgemIF4KBvECWaTQKNu0fu5d4dPXMpp+
jrxMEZPQZsivpOvklzV8O7wAkf/ZQhJdcB2m8uOfSPlJ91a4EEtXF9/GzzkXUi1P
bzt4NsmOag3227B3mO1Bc6yZdDBNu8wQ9apiJVCpqsxB9Dz0PCL4dHNa1u9g6Xo6
ElRgneV4HZp+LB125VoNabKuNH00bw==
=2yDl
-----END PGP SIGNATURE-----

@ -0,0 +1,11 @@
-----BEGIN PGP SIGNATURE-----
iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1oACgkQmix6mORX
xT0ScQf7Bip+quFq1CzDCTDxUhdTTOIpQcCfMKo1Jegpa2Hlm63XuK+6zVI9u6S+
dBXPdYeneMOqMPQUAaw3M06ZqohaDOt8Ti1EiQFGXCdOvbonTA52Lrd4EEZxwNnK
BdPuIh/8qCfozm5KbZe1bFyGVRAdNyf27KvzHgfBTirLtI+3MiOdL4bvNZbWRPfh
J84Ko+Ena8jPFgyz6nJv2Q2U/V3dCooLJAXs2vEG6owwk5J9zvSysWpHaJbXas5v
KXO9TOBBjf3+vxb1WVQa8ZYUU3+FIFes0RFVgOWghJXIooOcWrwOV2Q8z9qWXwoK
mMZ2oLS+z/7clXibK45KeRUeCX5DvQ==
=5oO1
-----END PGP SIGNATURE-----

@ -0,0 +1 @@
2730ac0859e1c2f450d54647c29ece43d095eb834e2130d4949dd1151317d013c072fa8f96f5f4b49836eff7c19a2eeeb5ca7483e5dac24c368bbcd522c27a00

@ -0,0 +1,11 @@
-----BEGIN PGP SIGNATURE-----
iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1oACgkQmix6mORX
xT3d+AgAwQvlwnKQLLuiZADGL+I922/YG317N2Re1EjC6WlMRZUKXH54fckRTyPm
4ZLyxVHy8LlUD2Q10g69opb7HRd/tV0miBJhn5OU1wIM3hqTgxNp9EFckK4md45k
osnhQJNDsFToxJL8zPP+KRs/aWPZs+FrRcH6k26lwLl2gTfyBDsaU11HFRVEN9yi
X41obVyKiVNlc9efSSvlLtRBSVt0VhAFhck+3t61H6D9H09QxaDGAqmduDua3Tg3
t5eqURuDfv3TfSztYgK3JBmG/6gVMsZodCgyC+8rhDDs6vSoDG30apx5Leg2rPbj
xuk2wi/WNzc94IgY9tVS3tAfT2k6yQ==
=6+Cv
-----END PGP SIGNATURE-----

@ -0,0 +1,11 @@
-----BEGIN PGP SIGNATURE-----
iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1oACgkQmix6mORX
xT3qtgf8CeDvxzi7lPghlrZtmYTTjaic4FZsGRWTPky3H24i4wSRDhG0L5sj4uPK
eLLlITr5a9j26UCas9HRSthiC8+EgIMAhSN0X482SQhUZHAW67ErIvaHlwL+ixMD
0T5pmsW8PKN3lV1TFMhNYSEC2GRG/4GF+3yQA8LR+BgeEu/E5nmysIH8vuQMkOD6
3pKA8VKNBml591j6UTqxoHtPX+rThaziz3Hy3+ekf5iWslllTTGPd2SWqTvnj2Ae
GvRzsbli+FEM0Aj/v8jUQnQzOz891QSvWR+fMfCqZimiJMc+GBzJ9umbcyQsB5tY
e26mAoYd9KEpGXMKN4biHbJZNp1GGw==
=x/MY
-----END PGP SIGNATURE-----

@ -0,0 +1,11 @@
-----BEGIN PGP SIGNATURE-----
iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1oACgkQmix6mORX
xT1PSwf+InTdlh+BVVy3RFszEL5vN7waSPYMImXhgd5EZ1d4Lxd6EeOwtNgWNKpG
E+Ps/Kw0jZDvoD49WJlcUjzDHBNHcE7C/L3GAWHV6WwklhtQaJ4EegsynWdSXz6k
fqJY6r58aGKGjpKPutRWAjvfcdC170+ZRsc2oi9xrAgHCpvXzTjq4+O9Ah0t5jwW
jcZ/Xubcw4vjsw774OucHbtwGsvRN5SDJ3IONOH8WCwhUP5vEEKvA6MYX0KGoTdS
3wTCyZTzU3qtTWxcbTCpiJIWbYwRR7TzLB/uydWHlAMzuz6coIiBpYsGiO6wkmfg
W+QvcE7wyW2jtb22pCImLyObyZ21VA==
=VjDv
-----END PGP SIGNATURE-----

@ -0,0 +1,11 @@
-----BEGIN PGP SIGNATURE-----
iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1oACgkQmix6mORX
xT2HQgf+MTUEnwzXK4zi76VI7C5cchGan26lIA2Ebq4vtYGKDqNSISOAAdWs9+nT
U6ZA6OIFo5qdeD6F/45s91IoDbxbhMDMFEsSijKASqiuZN5TZM1U2h2kWFAl/sEl
EI1RTygn+xDw/ah4V3/duuMFC+jRgvJ/LgemIF4KBvECWaTQKNu0fu5d4dPXMpp+
jrxMEZPQZsivpOvklzV8O7wAkf/ZQhJdcB2m8uOfSPlJ91a4EEtXF9/GzzkXUi1P
bzt4NsmOag3227B3mO1Bc6yZdDBNu8wQ9apiJVCpqsxB9Dz0PCL4dHNa1u9g6Xo6
ElRgneV4HZp+LB125VoNabKuNH00bw==
=2yDl
-----END PGP SIGNATURE-----

@ -0,0 +1,11 @@
-----BEGIN PGP SIGNATURE-----
iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1oACgkQmix6mORX
xT0ScQf7Bip+quFq1CzDCTDxUhdTTOIpQcCfMKo1Jegpa2Hlm63XuK+6zVI9u6S+
dBXPdYeneMOqMPQUAaw3M06ZqohaDOt8Ti1EiQFGXCdOvbonTA52Lrd4EEZxwNnK
BdPuIh/8qCfozm5KbZe1bFyGVRAdNyf27KvzHgfBTirLtI+3MiOdL4bvNZbWRPfh
J84Ko+Ena8jPFgyz6nJv2Q2U/V3dCooLJAXs2vEG6owwk5J9zvSysWpHaJbXas5v
KXO9TOBBjf3+vxb1WVQa8ZYUU3+FIFes0RFVgOWghJXIooOcWrwOV2Q8z9qWXwoK
mMZ2oLS+z/7clXibK45KeRUeCX5DvQ==
=5oO1
-----END PGP SIGNATURE-----

@ -0,0 +1 @@
2730ac0859e1c2f450d54647c29ece43d095eb834e2130d4949dd1151317d013c072fa8f96f5f4b49836eff7c19a2eeeb5ca7483e5dac24c368bbcd522c27a00

@ -0,0 +1,11 @@
-----BEGIN PGP SIGNATURE-----
iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1oACgkQmix6mORX
xT3d+AgAwQvlwnKQLLuiZADGL+I922/YG317N2Re1EjC6WlMRZUKXH54fckRTyPm
4ZLyxVHy8LlUD2Q10g69opb7HRd/tV0miBJhn5OU1wIM3hqTgxNp9EFckK4md45k
osnhQJNDsFToxJL8zPP+KRs/aWPZs+FrRcH6k26lwLl2gTfyBDsaU11HFRVEN9yi
X41obVyKiVNlc9efSSvlLtRBSVt0VhAFhck+3t61H6D9H09QxaDGAqmduDua3Tg3
t5eqURuDfv3TfSztYgK3JBmG/6gVMsZodCgyC+8rhDDs6vSoDG30apx5Leg2rPbj
xuk2wi/WNzc94IgY9tVS3tAfT2k6yQ==
=6+Cv
-----END PGP SIGNATURE-----

@ -0,0 +1,11 @@
-----BEGIN PGP SIGNATURE-----
iQEzBAABCAAdFiEE4qywN5M83qq3v3fUmix6mORXxT0FAmAiY1oACgkQmix6mORX
xT3qtgf8CeDvxzi7lPghlrZtmYTTjaic4FZsGRWTPky3H24i4wSRDhG0L5sj4uPK
eLLlITr5a9j26UCas9HRSthiC8+EgIMAhSN0X482SQhUZHAW67ErIvaHlwL+ixMD
0T5pmsW8PKN3lV1TFMhNYSEC2GRG/4GF+3yQA8LR+BgeEu/E5nmysIH8vuQMkOD6
3pKA8VKNBml591j6UTqxoHtPX+rThaziz3Hy3+ekf5iWslllTTGPd2SWqTvnj2Ae
GvRzsbli+FEM0Aj/v8jUQnQzOz891QSvWR+fMfCqZimiJMc+GBzJ9umbcyQsB5tY
e26mAoYd9KEpGXMKN4biHbJZNp1GGw==
=x/MY
-----END PGP SIGNATURE-----

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save