Add support for Maven Publisher API.

This commit is contained in:
Mark Paluch
2025-06-06 11:39:16 +02:00
parent 05dd2ee768
commit c8e9d8e38d
17 changed files with 766 additions and 292 deletions

View File

@@ -11,11 +11,13 @@ export PATH="$MAVEN_HOME/bin:$JAVA_HOME/bin:$PATH"
export JENKINS_HOME=/tmp/jenkins-home
export RELEASE_TOOLS_MAVEN_REPOSITORY=$(pwd)/maven-repository
export WORK_DIR=$(pwd)/work
export LOGS_DIR=$(pwd)/logs
export SETTINGS_XML=$(pwd)/ci/settings.xml
mkdir -p ${RELEASE_TOOLS_MAVEN_REPOSITORY}
mkdir -p ${LOGS_DIR}
mkdir -p ${WORK_DIR}
export GNUPGHOME=~/.gnupg/
mkdir -p ${GNUPGHOME}

View File

@@ -18,7 +18,7 @@
<properties>
<spring-data-bom.version>2023.0.0-M1</spring-data-bom.version>
<stagingRepository>orgspringframework-2453</stagingRepository>
<stagingRepository>deploymentId</stagingRepository>
<mongodb.version>5.3.1</mongodb.version>
</properties>
@@ -73,9 +73,9 @@
<repositories>
<repository>
<id>s01.oss.sonatype.org</id>
<id>central-publisher-api</id>
<url>
https://s01.oss.sonatype.org/service/local/repositories/${stagingRepository}/content/
https://central.sonatype.com/api/v1/publisher/deployment/${deploymentId}/download/
</url>
</repository>
</repositories>

View File

@@ -29,6 +29,18 @@
<username>${env.COMMERCIAL_USR}</username>
<password>${env.COMMERCIAL_PSW}</password>
</server>
<server>
<id>central-publisher-api</id>
<configuration>
<httpHeaders>
<property>
<name>Authorization</name>
<value>Bearer ${CENTRAL_BEARER}</value>
</property>
</httpHeaders>
</configuration>
</server>
</servers>
</settings>

View File

@@ -21,6 +21,7 @@ import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.release.model.SupportedProject;
import org.springframework.plugin.core.PluginRegistry;
import org.xmlbeam.XBProjector;
import org.xmlbeam.XBProjector.Flags;
import org.xmlbeam.config.DefaultXMLFactoriesConfig;

View File

@@ -19,6 +19,7 @@ import static org.springframework.data.release.model.Projects.*;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import lombok.SneakyThrows;
import java.nio.file.Path;
import java.util.List;
@@ -27,9 +28,12 @@ import java.util.function.Supplier;
import java.util.stream.Collectors;
import org.assertj.core.util.VisibleForTesting;
import org.springframework.data.release.deployment.DeploymentInformation;
import org.springframework.data.release.deployment.MavenPublisher;
import org.springframework.data.release.deployment.StagingRepository;
import org.springframework.data.release.git.BranchMapping;
import org.springframework.data.release.io.Workspace;
import org.springframework.data.release.model.ModuleIteration;
import org.springframework.data.release.model.Phase;
import org.springframework.data.release.model.ProjectAware;
@@ -56,6 +60,8 @@ public class BuildOperations {
private final @NonNull Logger logger;
private final @NonNull MavenProperties properties;
private final @NonNull BuildExecutor executor;
private final @NonNull MavenPublisher publisher;
private final Workspace workspace;
/**
* Updates all inter-project dependencies based on the given {@link TrainIteration} and release {@link Phase}.
@@ -120,35 +126,6 @@ public class BuildOperations {
return doWithBuildSystem(iteration, (system, module) -> system.prepareVersion(module, phase));
}
/**
* Opens a repository to stage artifacts for this {@link ModuleIteration}.
*
* @param iteration must not be {@literal null}.
*/
public void open(ModuleIteration iteration) {
doWithBuildSystem(iteration, (buildSystem, moduleIteration) -> buildSystem.open(iteration.getTrain()));
}
/**
* Closes a repository to stage artifacts for this {@link ModuleIteration}.
*
* @param iteration must not be {@literal null}.
* @param stagingRepository must not be {@literal null}.
*/
public void close(ModuleIteration iteration, StagingRepository stagingRepository) {
Assert.notNull(stagingRepository, "StagingRepository must not be null");
Assert.isTrue(stagingRepository.isPresent(), "StagingRepository must be present");
Train train = iteration.getTrain();
doWithBuildSystem(iteration, (buildSystem, moduleIteration) -> {
buildSystem.close(train, stagingRepository);
return null;
});
}
/**
* Performs a local build for all modules in the given {@link TrainIteration}.
*
@@ -163,55 +140,14 @@ public class BuildOperations {
}
/**
* Open a staging repository.
* Promote the staging repository by publishing the deployment.
*
* @param iteration
* @return
*/
public StagingRepository openStagingRepository(TrainIteration iteration) {
BuildSystem orchestrator = getRepositoryOrchestrator(iteration.getTrain());
return iteration.isPublic() ? orchestrator.open(iteration.getTrain()) : StagingRepository.EMPTY;
}
/**
* Close a staging repository.
*
* @param stagingRepository
* @return
*/
public void closeStagingRepository(Train train, StagingRepository stagingRepository) {
BuildSystem orchestrator = getRepositoryOrchestrator(train);
if (stagingRepository.isPresent()) {
orchestrator.close(train, stagingRepository);
}
}
private BuildSystem getRepositoryOrchestrator(Train train) {
SupportedProject project = train.getSupportedProject(BUILD);
BuildSystem orchestrator = buildSystems.getRequiredPluginFor(project);
return orchestrator.withJavaVersion(executor.detectJavaVersion(project));
}
/**
* Promote the staging repository.
*
* @param train must not be {@literal null}.
* @param stagingRepository must not be {@literal null}.
* @return
*/
public void releaseStagingRepository(Train train, StagingRepository stagingRepository) {
BuildSystem orchestrator = getRepositoryOrchestrator(train);
if (stagingRepository.isPresent()) {
orchestrator.release(train, stagingRepository);
}
public void publishDeployment(TrainIteration iteration, StagingRepository stagingRepository) {
publisher.publish(iteration, stagingRepository);
}
/**
@@ -228,22 +164,6 @@ public class BuildOperations {
});
}
/**
* Releases a repository of staged artifacts for this {@link ModuleIteration}.
*
* @param iteration
* @param stagingRepository
*/
public void release(ModuleIteration iteration, StagingRepository stagingRepository) {
Train train = iteration.getTrain();
doWithBuildSystem(iteration, (buildSystem, moduleIteration) -> {
buildSystem.release(train, stagingRepository);
return null;
});
}
public void buildDocumentation(TrainIteration iteration) {
Streamable<ModuleIteration> of = Streamable.of(iteration.getModulesExcept(BOM, COMMONS, BUILD));
@@ -268,17 +188,14 @@ public class BuildOperations {
*/
public List<DeploymentInformation> performRelease(TrainIteration iteration) {
StagingRepository stagingRepository = iteration.isPublic() //
? openStagingRepository(iteration) //
: StagingRepository.EMPTY;
StagingRepository localStaging = iteration.isPublic() ? initializeStagingRepository() : StagingRepository.EMPTY;
StagingRepository stagingRepository = StagingRepository.EMPTY;
BuildExecutor.Summary<DeploymentInformation> summary = executor.doWithBuildSystemOrdered(iteration,
(buildSystem, moduleIteration) -> buildSystem.deploy(moduleIteration, stagingRepository));
(buildSystem, moduleIteration) -> buildSystem.deploy(moduleIteration, localStaging));
Train train = iteration.getTrain();
if (stagingRepository.isPresent()) {
closeStagingRepository(train, stagingRepository);
if (iteration.isPublic()) {
stagingRepository = uploadDeployment(iteration.getModule(BOM), localStaging);
}
smokeTests(iteration, stagingRepository);
@@ -286,12 +203,81 @@ public class BuildOperations {
logger.log(iteration, "Release: %s", summary);
if (stagingRepository.isPresent()) {
releaseStagingRepository(train, stagingRepository);
publishDeployment(iteration, stagingRepository);
}
return summary.getExecutions().stream()
.map(BuildExecutor.ExecutionResult::getResult)
.collect(Collectors.toList());
return summary.getExecutions().stream().map(BuildExecutor.ExecutionResult::getResult).collect(Collectors.toList());
}
/**
* Performs the staging build for all modules in the given {@link TrainIteration} without deploying artifacts
* remotely.
*
* @param iteration must not be {@literal null}.
* @return
*/
public List<DeploymentInformation> stageRelease(TrainIteration iteration) {
StagingRepository localStaging = initializeStagingRepository();
BuildExecutor.Summary<DeploymentInformation> summary = executor.doWithBuildSystemOrdered(iteration,
(buildSystem, moduleIteration) -> buildSystem.deploy(moduleIteration, localStaging));
logger.log(iteration, "Release: %s", summary);
return summary.getExecutions().stream().map(BuildExecutor.ExecutionResult::getResult).collect(Collectors.toList());
}
/**
* Performs the staging build for {@link ModuleIteration} without deploying artifacts remotely.
*
* @param module must not be {@literal null}.
* @return
*/
public DeploymentInformation stageRelease(ModuleIteration module) {
StagingRepository localStaging = initializeStagingRepository();
return doWithBuildSystem(module,
(buildSystem, moduleIteration) -> buildSystem.deploy(moduleIteration, localStaging));
}
public void uploadDeployment(TrainIteration iteration) {
StagingRepository localStaging = publisher.getStagingRepository();
StagingRepository stagingRepository = uploadDeployment(iteration.getModule(BOM), localStaging);
logger.log(iteration, "Created deployment: %s", stagingRepository);
}
@SneakyThrows
public void validateDeployment(TrainIteration iteration, StagingRepository deploymentId) {
MavenPublisher.DeploymentStatus status = publisher.validate(iteration, deploymentId);
logger.log(iteration, "Deployment validated: %s, \n%s", status.getDeploymentState(),
status.getPurls().stream().map(it -> " * " + it).collect(Collectors.joining(System.lineSeparator())));
}
@SneakyThrows
private StagingRepository uploadDeployment(ModuleIteration iteration, StagingRepository localStaging) {
String deploymentName;
if (iteration.getProject() == BOM) {
deploymentName = String.format("Spring Data %s", iteration.getTrainIteration().getName());
} else {
deploymentName = String.format("Spring Data %s", iteration);
}
StagingRepository deploymentId = publisher.upload(iteration, deploymentName, localStaging);
publisher.validate(iteration.getTrainIteration(), deploymentId);
return deploymentId;
}
@SneakyThrows
private StagingRepository initializeStagingRepository() {
return publisher.initializeStagingRepository();
}
/**
@@ -420,8 +406,7 @@ public class BuildOperations {
* @param function must not be {@literal null}.
* @return
*/
private <T, S extends ProjectAware> T doWithBuildSystem(S module,
BiFunction<BuildSystem, S, T> function) {
private <T, S extends ProjectAware> T doWithBuildSystem(S module, BiFunction<BuildSystem, S, T> function) {
Assert.notNull(module, "ModuleIteration must not be null!");

View File

@@ -69,27 +69,6 @@ interface BuildSystem extends Plugin<SupportedProject> {
*/
<M extends ProjectAware> M triggerPreReleaseCheck(M module);
/**
* Open a remote repository for staging artifacts.
*
* @param train must not be {@literal null}.
*/
StagingRepository open(Train train);
/**
* Close a remote repository for staging artifacts.
*
* @param train must not be {@literal null}.
*/
void close(Train train, StagingRepository stagingRepository);
/**
* Release a remote repository of staged artifacts.
*
* @param train must not be {@literal null}.
*/
void release(Train train, StagingRepository stagingRepository);
<M extends ProjectAware> M triggerBuild(M module);
<M extends ProjectAware> M triggerDocumentationBuild(M module);

View File

@@ -225,55 +225,6 @@ class MavenBuildSystem implements BuildSystem {
return module;
}
/**
* Perform a {@literal nexus-staging:rc-open} and extract the {@code stagingProfileId} from the results.
*/
@Override
public StagingRepository open(Train train) {
Assert.notNull(properties.getMavenCentral(), "Maven Central properties must not be null");
Assert.hasText(properties.getMavenCentral().getStagingProfileId(), "Staging Profile Identifier must not be empty");
CommandLine arguments = CommandLine.of(goal("nexus-staging:rc-open"), //
profile("central"), //
arg("stagingProfileId").withValue(properties.getMavenCentral().getStagingProfileId()), //
arg("openedRepositoryMessageFormat").withValue("'" + REPO_OPENING_TAG + "%s" + REPO_CLOSING_TAG + "'"))
.andIf(!ObjectUtils.isEmpty(properties.getSettingsXml()), () -> settingsXml(properties.getSettingsXml()));
MavenRuntime.MavenInvocationResult invocationResult = mvn.execute(train.getSupportedProject(BUILD), arguments);
List<String> rcOpenLogContents = invocationResult.getLog();
String stagingRepositoryId = rcOpenLogContents.stream() //
.filter(line -> line.contains(REPO_OPENING_TAG) && !line.contains("%s")) //
.reduce((first, second) -> second) // find the last entry, a.k.a. the most recent log line
.map(s -> s.substring( //
s.indexOf(REPO_OPENING_TAG) + REPO_OPENING_TAG.length(), //
s.indexOf(REPO_CLOSING_TAG))) //
.orElse("");
logger.log(BUILD, "Opened staging repository with Id: " + stagingRepositoryId);
return StagingRepository.of(stagingRepositoryId);
}
/**
* Perform a {@literal nexus-staging:rc-close}.
*/
@Override
public void close(Train train, StagingRepository stagingRepository) {
Assert.notNull(stagingRepository, "StagingRepository must not be null");
Assert.isTrue(stagingRepository.isPresent(), "StagingRepository must be present");
CommandLine arguments = CommandLine.of(goal("nexus-staging:rc-close"), //
profile("central"), //
arg("stagingRepositoryId").withValue(stagingRepository.getId()))
.andIf(!ObjectUtils.isEmpty(properties.getSettingsXml()), () -> settingsXml(properties.getSettingsXml()));
mvn.execute(train.getSupportedProject(BUILD), arguments);
}
/*
* (non-Javadoc)
* @see org.springframework.data.release.build.BuildSystem#triggerBuild(org.springframework.data.release.model.ModuleIteration)
@@ -331,7 +282,7 @@ class MavenBuildSystem implements BuildSystem {
deployToArtifactory(module, information);
deployToMavenCentral(module, information);
doDeploy(module, information);
}
/**
@@ -382,7 +333,7 @@ class MavenBuildSystem implements BuildSystem {
* @param module must not be {@literal null}.
* @param deploymentInformation must not be {@literal null}.
*/
private void deployToMavenCentral(ModuleIteration module, DeploymentInformation deploymentInformation) {
private void doDeploy(ModuleIteration module, DeploymentInformation deploymentInformation) {
Assert.notNull(module, "Module iteration must not be null!");
Assert.notNull(deploymentInformation, "DeploymentInformation iteration must not be null!");
@@ -392,19 +343,24 @@ class MavenBuildSystem implements BuildSystem {
return;
}
logger.log(module, "Deploying artifacts to Sonatype OSS Nexus…");
StagingRepository stagingRepository = deploymentInformation.getStagingRepositoryId();
if (stagingRepository.isPresent()) {
logger.log(module, "Staging artifacts to %s…", stagingRepository);
} else {
logger.log(module, "Deploying artifacts Maven Central …");
}
Gpg gpg = getGpg();
CommandLine arguments = CommandLine.of(Goal.CLEAN, Goal.DEPLOY, //
profile("ci,release,central"), //
profile("ci,release"), //
SKIP_TESTS, //
arg("gpg.executable").withValue(gpg.getExecutable()), //
arg("gpg.keyname").withValue(gpg.getKeyname()), //
arg("gpg.passphrase").withValue(gpg.getPassphrase())) //
.andIf(!ObjectUtils.isEmpty(properties.getSettingsXml()), settingsXml(properties.getSettingsXml()))
.andIf(deploymentInformation.getStagingRepositoryId().isPresent(),
() -> arg("stagingRepositoryId").withValue(deploymentInformation.getStagingRepositoryId()))
.andIf(stagingRepository.isPresent(), () -> arg("altDeploymentRepository").withValue(stagingRepository))
.andIf(gpg.hasSecretKeyring(), () -> arg("gpg.secretKeyring").withValue(gpg.getSecretKeyring()));
mvn.execute(module.getSupportedProject(), arguments);
@@ -437,30 +393,14 @@ class MavenBuildSystem implements BuildSystem {
profile(profile), //
arg("s").withValue("settings.xml"), //
arg("spring-data-bom.version").withValue(iteration.getReleaseTrainNameAndVersion())) //
.andIf(mavenCentral, arg("stagingRepository").withValue(stagingRepository.getId()));
.andIf(mavenCentral, arg("deploymentId").withValue(stagingRepository.getId())) //
.andIf(mavenCentral, arg("CENTRAL_BEARER").withValue(properties.getMavenCentral().getBearer()));
mvn.execute(smokeTests, arguments);
logger.log(iteration, "✅ Smoke tests passed. Do not smoke 🚭. It's unhealthy.");
}
/**
* Perform a {@literal nexus-staging:rc-release}.
*/
@Override
public void release(Train train, StagingRepository stagingRepository) {
Assert.notNull(stagingRepository, "StagingRepository must not be null");
Assert.isTrue(stagingRepository.isPresent(), "StagingRepository must be present");
CommandLine arguments = CommandLine.of(goal("nexus-staging:rc-release"), //
profile("central"), //
arg("stagingRepositoryId").withValue(stagingRepository.getId()))
.andIf(!ObjectUtils.isEmpty(properties.getSettingsXml()), () -> settingsXml(properties.getSettingsXml()));
mvn.execute(train.getSupportedProject(BUILD), arguments);
}
@Override
public <M extends ProjectAware> M triggerDocumentationBuild(M module) {
@@ -629,14 +569,6 @@ class MavenBuildSystem implements BuildSystem {
logger.log(BUILD, "Verifying Maven Staging Authentication…");
mvn.execute(train.getSupportedProject(BUILD), //
CommandLine.of(goal("nexus-staging:rc-list-profiles"), //
profile("central")));
Assert.notNull(properties.getMavenCentral(),
"Maven Central properties are not set (deployment.maven-central.staging-profile-id=…)");
Assert.hasText(properties.getMavenCentral().getStagingProfileId(),
"Staging Profile Id is not set (deployment.maven-central.staging-profile-id=…)");
return;
}
}

View File

@@ -61,7 +61,7 @@ class ReleaseCommands extends TimedCommand {
@NonNull BuildOperations build;
@NonNull IssueTrackerCommands tracker;
@NonNull GitHubCommands gitHub;
private final Logger logger;
@NonNull Logger logger;
/**
* Composite command to prepare a release.
@@ -130,32 +130,6 @@ class ReleaseCommands extends TimedCommand {
git.commit(iteration, "Release version %s.");
}
@CliCommand(value = "repository open")
public void repositoryOpen(@CliOption(key = "", mandatory = true) TrainIteration iteration) {
if (iteration.isPublic()) {
build.open(iteration.getModule(Projects.BUILD));
}
}
@CliCommand(value = "repository close")
public void repositoryClose(@CliOption(key = "", mandatory = true) TrainIteration iteration,
@CliOption(key = "stagingRepositoryId", mandatory = true) String stagingRepositoryId) {
if (iteration.isPublic()) {
build.close(iteration.getModule(Projects.BUILD), StagingRepository.of(stagingRepositoryId));
}
}
@CliCommand(value = "release repository")
public void repositoryRelease(@CliOption(key = "", mandatory = true) TrainIteration iteration,
@CliOption(key = "stagingRepositoryId", mandatory = true) String stagingRepositoryId) {
if (iteration.isPublic()) {
build.release(iteration.getModule(Projects.BUILD), StagingRepository.of(stagingRepositoryId));
}
}
@CliCommand(value = "release build")
public void buildRelease(@CliOption(key = "", mandatory = true) TrainIteration iteration, //
@CliOption(key = "project", mandatory = false) String projectName) {
@@ -179,6 +153,38 @@ class ReleaseCommands extends TimedCommand {
}
}
@CliCommand(value = "release local-stage")
public void stageRelease(@CliOption(key = "", mandatory = true) TrainIteration iteration, //
@CliOption(key = "project", mandatory = false) String projectName) {
git.checkout(iteration);
if (!iteration.isPublic()) {
deployment.verifyAuthentication(iteration);
}
if (projectName != null) {
Project project = Projects.requiredByName(projectName);
ModuleIteration module = iteration.getModule(project);
build.stageRelease(module);
} else {
build.stageRelease(iteration);
}
}
@CliCommand(value = "release central upload")
public void uploadDeployment(@CliOption(key = "", mandatory = true) TrainIteration iteration) {
build.uploadDeployment(iteration);
}
@CliCommand(value = "release central validate")
public void validateDeployment(@CliOption(key = "", mandatory = true) TrainIteration iteration,
@CliOption(key = "deploymentId", mandatory = true) String deploymentId) {
build.validateDeployment(iteration, StagingRepository.of(deploymentId));
}
/**
* Concludes the release of the given {@link TrainIteration}.
*

View File

@@ -18,12 +18,14 @@ package org.springframework.data.release.deployment;
import lombok.Data;
import java.net.URI;
import java.time.Duration;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.data.release.model.Gpg;
import org.springframework.data.release.model.Password;
import org.springframework.data.release.model.SupportStatusAware;
import org.springframework.data.release.utils.HttpBasicCredentials;
import org.springframework.data.util.Streamable;
import org.springframework.stereotype.Component;
import org.springframework.util.Assert;
@@ -99,7 +101,10 @@ public class DeploymentProperties implements InitializingBean {
@Data
public static class MavenCentral {
private String stagingProfileId;
private String username;
private Password password;
private String centralApiBaseUrl = "https://central.sonatype.com";
private Duration validationTimeout = Duration.ofMinutes(5);
private Gpg gpg;
@@ -109,10 +114,18 @@ public class DeploymentProperties implements InitializingBean {
public void validate() {
if (!StringUtils.hasText(stagingProfileId)) {
throw new IllegalArgumentException("No staging profile Id for Maven Central");
if (password == null) {
throw new IllegalArgumentException("No Maven Publisher API authentication configured");
}
}
public HttpBasicCredentials getHttpCredentials() {
return new HttpBasicCredentials(username, password);
}
public Password getBearer() {
return Password.of(getHttpCredentials().encode());
}
}
@Data

View File

@@ -0,0 +1,419 @@
/*
* Copyright 2025 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
*
* http://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 org.springframework.data.release.deployment;
import lombok.Data;
import lombok.Getter;
import lombok.SneakyThrows;
import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.file.FileVisitResult;
import java.nio.file.FileVisitor;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.BasicFileAttributes;
import java.time.Duration;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicLong;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.core.io.FileSystemResource;
import org.springframework.data.release.io.Workspace;
import org.springframework.data.release.model.ModuleIteration;
import org.springframework.data.release.model.TrainIteration;
import org.springframework.data.release.utils.Logger;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.FormHttpMessageConverter;
import org.springframework.http.converter.StringHttpMessageConverter;
import org.springframework.stereotype.Component;
import org.springframework.util.Assert;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestOperations;
import org.springframework.web.util.DefaultUriBuilderFactory;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
/**
* Component to publish artifacts to Maven Central using the Maven Publisher API.
*
* @author Mark Paluch
*/
@Component
public class MavenPublisher {
private static final String UPLOAD_URI = "/api/v1/publisher/upload?name={deploymentName}&publishingType=USER_MANAGED";
private static final String DEPLOYMENT_STATUS = "/api/v1/publisher/status?id={deploymentId}";
private static final String PUBLISH_DEPLOYMENT = "/api/v1/publisher/deployment/{deploymentId}";
private final Logger logger;
private final Workspace workspace;
private final DeploymentProperties properties;
private final RestOperations restTemplate;
public MavenPublisher(Logger logger, Workspace workspace, DeploymentProperties properties,
RestTemplateBuilder builder) {
this.logger = logger;
this.workspace = workspace;
this.properties = properties;
this.restTemplate = createOperations(
builder.additionalMessageConverters(new FormHttpMessageConverter(), new StringHttpMessageConverter()),
properties.getMavenCentral());
}
private static RestOperations createOperations(RestTemplateBuilder templateBuilder,
DeploymentProperties.MavenCentral properties) {
String authentication = "Bearer " + properties.getBearer().toString();
return templateBuilder.uriTemplateHandler(new DefaultUriBuilderFactory(properties.getCentralApiBaseUrl()))
.defaultHeader(HttpHeaders.AUTHORIZATION, authentication).build();
}
/**
* Initialize and clear the staging repository.
*
* @return
*/
@SneakyThrows
public StagingRepository initializeStagingRepository() {
File stagingDirectory = getStagingDirectory();
File zip = getStagingFile();
workspace.delete(stagingDirectory.toPath(), "central-staging");
if (zip.exists()) {
if (!zip.delete()) {
throw new IllegalStateException(String.format("Unable to delete '%s'", zip));
}
}
if (!stagingDirectory.exists() && !stagingDirectory.mkdirs()) {
throw new IllegalStateException(String.format("Unable to create '%s'", stagingDirectory));
}
String[] list = stagingDirectory.list();
if (list == null) {
throw new IllegalStateException(String.format("Staging directory cannot be listed '%s'", stagingDirectory));
}
if (list.length != 0) {
throw new IllegalStateException(String.format("Staging directory is not empty: %s", Arrays.asList(list)));
}
return getStagingRepository(stagingDirectory);
}
/**
* Return the staging repository.
*
* @return
*/
public StagingRepository getStagingRepository() {
return getStagingRepository(getStagingDirectory());
}
private StagingRepository getStagingRepository(File stagingDirectory) {
return LocalStagingRepository.of(stagingDirectory);
}
/**
* Upload a compressed version of the staged artifacts to Maven Publisher.
*
* @param iteration
* @param deploymentName
* @param localStaging
* @return
* @throws IOException
*/
public StagingRepository upload(ModuleIteration iteration, String deploymentName, StagingRepository localStaging)
throws IOException {
Assert.notNull(localStaging, "Local StagingRepository must not be null");
Assert.isTrue(localStaging.isPresent(), "Local StagingRepository must be present");
Assert.isInstanceOf(LocalStagingRepository.class, localStaging);
File zipFile = compressStagedArtifacts(iteration.getTrainIteration(), (LocalStagingRepository) localStaging);
return uploadStagingFile(iteration, deploymentName, zipFile);
}
private StagingRepository uploadStagingFile(ModuleIteration iteration, String deploymentName, File zipFile) {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
headers.setAccept(Collections.singletonList(MediaType.TEXT_PLAIN));
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
body.add("bundle", new FileSystemResource(zipFile));
logger.log(iteration, "🚛 Uploading staging file …");
ResponseEntity<String> upload = restTemplate.postForEntity(UPLOAD_URI, new HttpEntity<>(body, headers),
String.class, Collections.singletonMap("deploymentName", deploymentName));
if (upload.getStatusCode().is2xxSuccessful()) {
String deploymentId = upload.getBody();
logger.log(iteration, "📦 Staging file uploaded successfully. Created deploymentId '%s'", deploymentId);
return StagingRepository.of(deploymentId);
}
throw new IllegalStateException(
String.format("😵‍💫 Staging upload of '%s' failed: %s %s", zipFile, upload.getStatusCode(), upload.getBody()));
}
/**
* Retrieve the deployment status.
*
* @param stagingRepository
* @return
*/
public DeploymentStatus getStatus(StagingRepository stagingRepository) {
Assert.notNull(stagingRepository, "StagingRepository must not be null");
Assert.isTrue(stagingRepository.isPresent(), "StagingRepository must be present");
HttpHeaders headers = new HttpHeaders();
headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON));
ResponseEntity<DeploymentStatus> status = restTemplate.exchange(DEPLOYMENT_STATUS, HttpMethod.POST,
new HttpEntity<>(headers), DeploymentStatus.class,
Collections.singletonMap("deploymentId", stagingRepository.getId()));
if (status.getStatusCode().is2xxSuccessful()) {
return status.getBody();
}
throw new IllegalStateException(
String.format("😵‍💫 Obtaining deployment status for deploymentId '%s' failed: %s %s", stagingRepository,
status.getStatusCode(), status.getBody()));
}
/**
* Publish the deployment to Maven Central.
*
* @param iteration
* @param stagingRepository
*/
public void publish(TrainIteration iteration, StagingRepository stagingRepository) {
Assert.notNull(stagingRepository, "StagingRepository must not be null");
Assert.isTrue(stagingRepository.isPresent(), "StagingRepository must be present");
logger.log(iteration, "🛳️ Publishing deployment '%s'…", stagingRepository.getId());
ResponseEntity<String> publish = restTemplate.postForEntity(PUBLISH_DEPLOYMENT, null, String.class,
Collections.singletonMap("deploymentId", stagingRepository.getId()));
if (publish.getStatusCodeValue() == HttpStatus.NO_CONTENT.value()) {
logger.log(iteration, "🚀 Au revoir. Bye bye. See you later at Maven Central.");
return;
}
throw new IllegalStateException(String.format("😵‍💫 Publishing deployment for deploymentId '%s' failed: %s %s",
stagingRepository, publish.getStatusCode(), publish.getBody()));
}
File compressStagedArtifacts(TrainIteration iteration, LocalStagingRepository stagingDirectory) throws IOException {
File zipFile = getStagingFile();
logger.log(iteration, "🗜️ Creating staging file '%s'…", zipFile);
FileOutputStream zip = new FileOutputStream(zipFile);
ZipOutputStream zos = new ZipOutputStream(new BufferedOutputStream(zip));
AtomicLong counter = new AtomicLong();
Path root = stagingDirectory.getFile().toPath();
Files.walkFileTree(root, new FileVisitor<Path>() {
@Override
public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException {
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
if (file.getFileName().toString().contains("maven-metadata.")
|| file.getFileName().toString().startsWith(".")) {
return FileVisitResult.CONTINUE;
}
String entry = root.relativize(file).toString();
zos.putNextEntry(new ZipEntry(entry));
Files.copy(file, zos);
zos.closeEntry();
counter.incrementAndGet();
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult visitFileFailed(Path file, IOException exc) throws IOException {
throw exc;
}
@Override
public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException {
if (exc != null) {
throw exc;
}
return FileVisitResult.CONTINUE;
}
});
zos.close();
if (counter.get() == 0) {
throw new IllegalStateException(String.format("Staging directory '%s' empty", stagingDirectory));
}
logger.log(iteration, "🗜️ Created staging file '%s' with %d files", zipFile, counter.get());
return zipFile;
}
/**
* Validate the deployment.
*
* @param iteration
* @param deploymentId
* @return
* @throws TimeoutException
*/
public DeploymentStatus validate(TrainIteration iteration, StagingRepository deploymentId)
throws TimeoutException, InterruptedException {
long maxWaitSeconds = TimeUnit.MILLISECONDS
.toSeconds(properties.getMavenCentral().getValidationTimeout().toMillis());
Instant start = Instant.now();
boolean validating = false;
while (Duration.between(start, Instant.now()).get(ChronoUnit.SECONDS) < maxWaitSeconds) {
DeploymentStatus status = getStatus(deploymentId);
switch (status.getDeploymentState()) {
case PENDING:
case VALIDATING:
if (!validating) {
logger.log(iteration, "⏳ Validation. Waiting for completion…");
validating = true;
}
break;
case VALIDATED:
case PUBLISHED:
logger.log(iteration, "✅ Validation successful.");
return status;
case FAILED:
logger.log(iteration, "⚠️ Validation failed: %s", status.getErrorDetail());
throw new IllegalStateException("Deployment Validation failed");
}
TimeUnit.SECONDS.sleep(5);
}
throw new TimeoutException(String.format("Validation timeout '%d seconds' exceeded: %d seconds", maxWaitSeconds,
Duration.between(start, Instant.now()).get(ChronoUnit.SECONDS)));
}
File getStagingDirectory() {
return new File(workspace.getStagingDirectory(), "central-staging");
}
File getStagingFile() {
return new File(workspace.getStagingDirectory(), "central-staging.zip");
}
@JsonIgnoreProperties(ignoreUnknown = true)
@Data
public static class DeploymentStatus {
private String deploymentId;
private DeploymentState deploymentState;
private Map<String, List<String>> errors = new LinkedHashMap<>();
private List<String> purls = new ArrayList<>();
public String getErrorDetail() {
StringBuilder errorMessage = new StringBuilder();
errorMessage.append("\n\n").append("Deployment ").append(getDeploymentId()).append(" failed\n");
for (Map.Entry<String, List<String>> errorCategory : getErrors().entrySet()) {
errorMessage.append(errorCategory.getKey()).append(":\n");
for (String error : errorCategory.getValue()) {
errorMessage.append(" - ").append(error).append("\n");
}
errorMessage.append("\n");
}
return errorMessage.toString();
}
enum DeploymentState {
PENDING, VALIDATING, VALIDATED, PUBLISHING, PUBLISHED, FAILED
}
}
@Getter
static class LocalStagingRepository extends StagingRepository {
private final File file;
LocalStagingRepository(File file) {
super(String.format("central-staging::default::file://%s", file.getAbsolutePath()));
this.file = file;
}
public static StagingRepository of(File file) {
Assert.notNull(file, "File must not be null!");
Assert.isTrue(file.exists(), () -> String.format("File '%s' must exist!", file));
return new LocalStagingRepository(file);
}
}
}

View File

@@ -15,8 +15,6 @@
*/
package org.springframework.data.release.deployment;
import lombok.Value;
import org.springframework.util.ObjectUtils;
/**
@@ -24,12 +22,19 @@ import org.springframework.util.ObjectUtils;
*
* @author Mark Paluch
*/
@Value(staticConstructor = "of")
public class StagingRepository {
public static final StagingRepository EMPTY = StagingRepository.of("");
String id;
private final String id;
protected StagingRepository(String id) {
this.id = id;
}
public static StagingRepository of(String id) {
return new StagingRepository(id);
}
public boolean isEmpty() {
return ObjectUtils.isEmpty(id);
@@ -47,4 +52,23 @@ public class StagingRepository {
return "(empty)";
}
public String getId() {
return this.id;
}
@Override
public boolean equals(Object o) {
if (!(o instanceof StagingRepository)) {
return false;
}
StagingRepository that = (StagingRepository) o;
return ObjectUtils.nullSafeEquals(id, that.id);
}
@Override
public int hashCode() {
return ObjectUtils.nullSafeHashCode(id);
}
}

View File

@@ -33,9 +33,9 @@ import org.springframework.stereotype.Component;
@Data
@Component
@ConfigurationProperties(prefix = "io")
class IoProperties {
public class IoProperties {
private File workDir, logs;
private File workDir, stagingDir, logs;
public void setWorkDir(String workDir) {
@@ -43,4 +43,10 @@ class IoProperties {
this.workDir = new File(workDir.replace("~", FileUtils.getUserDirectoryPath()));
}
public void setStagingDir(String stagingDir) {
log.info(String.format("🔧 Using %s as staging directory!", stagingDir));
this.stagingDir = new File(stagingDir.replace("~", FileUtils.getUserDirectoryPath()));
}
}

View File

@@ -15,8 +15,6 @@
*/
package org.springframework.data.release.io;
import static org.springframework.data.release.utils.StreamUtils.*;
import lombok.AccessLevel;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
@@ -31,17 +29,14 @@ import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.Arrays;
import java.util.Collections;
import java.util.Optional;
import java.util.Scanner;
import java.util.function.Predicate;
import java.util.stream.Stream;
import javax.annotation.PostConstruct;
import org.springframework.core.io.Resource;
import org.springframework.core.io.support.ResourcePatternResolver;
import org.springframework.data.release.model.Project;
import org.springframework.data.release.model.Projects;
import org.springframework.data.release.model.SupportedProject;
import org.springframework.data.release.utils.Logger;
@@ -52,6 +47,7 @@ import org.springframework.util.Assert;
* Abstraction of the workspace that is used to work with the {@link Project}'s repositories, execute builds, etc.
*
* @author Oliver Gierke
* @author Mark Paluch
*/
@Component
@RequiredArgsConstructor
@@ -61,7 +57,6 @@ public class Workspace {
private static final Charset UTF_8 = StandardCharsets.UTF_8;
@NonNull IoProperties ioProperties;
@NonNull ResourcePatternResolver resolver;
@NonNull Logger logger;
/**
@@ -73,6 +68,15 @@ public class Workspace {
return ioProperties.getWorkDir();
}
/**
* Returns the current staging directory.
*
* @return
*/
public File getStagingDirectory() {
return ioProperties.getStagingDir();
}
/**
* Returns the current logs directory.
*
@@ -90,35 +94,23 @@ public class Workspace {
public void cleanup() throws IOException {
delete(getWorkingDirectory().toPath(), "workspace");
delete(getStagingDirectory().toPath(), "staging");
delete(getLogsDirectory().toPath(), "logs");
}
private void delete(Path path, String type) throws IOException {
public void delete(Path path, String type) throws IOException {
logger.log("Workspace", "Cleaning up %s directory at %s.", type, path.toAbsolutePath());
Files.walkFileTree(path, new SimpleFileVisitor<Path>() {
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
Files.delete(file);
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException {
if (!path.equals(dir)) {
Files.delete(dir);
}
return FileVisitResult.CONTINUE;
}
});
purge(path, it -> !path.equals(it));
}
public void purge(Path path, Predicate<Path> filter) throws IOException {
if (!path.toFile().exists()) {
return;
}
Files.walkFileTree(path, new SimpleFileVisitor<Path>() {
@Override
@@ -187,18 +179,6 @@ public class Workspace {
return new File(getProjectDirectory(project), name);
}
public Stream<File> getFiles(String pattern, SupportedProject project) {
File projectDirectory = getProjectDirectory(project);
String patternToLookup = String.format("file:%s/%s", projectDirectory.getAbsolutePath(), pattern);
try {
return Arrays.stream(resolver.getResources(patternToLookup)).map(wrap(Resource::getFile));
} catch (IOException o_O) {
throw new RuntimeException(o_O);
}
}
public boolean processFile(String filename, SupportedProject project, LineCallback callback) {
File file = getFile(filename, project);

View File

@@ -38,9 +38,13 @@ public class HttpBasicCredentials {
*/
public String toString() {
return "Basic ".concat(encode());
}
public String encode() {
String header = username.concat(":").concat(password.toString());
byte[] encodedAuth = Base64.getEncoder().encode(header.getBytes(StandardCharsets.US_ASCII));
return "Basic ".concat(new String(encodedAuth));
return new String(encodedAuth);
}
}

View File

@@ -8,28 +8,25 @@ git.gpg.passphrase=${GIT_SIGNING_KEY_PASSWORD}
# Spring OpenSource Artifactory
deployment.opensource.username=${REPO_SPRING_IO_USR}
deployment.opensource.password=${REPO_SPRING_IO_PSW}
deployment.opensource.api-key=${REPO_SPRING_IO_PSW}
deployment.settings-xml=${SETTINGS_XML}
# Spring Commercial Artifactory
deployment.commercial.username=${COMMERCIAL_USR}
deployment.commercial.password=${COMMERCIAL_PSW}
deployment.commercial.api-key=${COMMERCIAL_PSW}
deployment.maven-central.stagingProfileId=${STAGING_PROFILE_ID}
deployment.maven-central.gpg.keyname=003C0425
deployment.maven-central.gpg.passphrase=${MAVEN_SIGNING_KEY_PASSWORD}
deployment.maven-central.gpg.executable=/usr/bin/gpg
deployment.maven-central.username=${CENTRAL_TOKEN_USERNAME}
deployment.maven-central.password=${CENTRAL_TOKEN_PASSWORD}
sagan.key=n/a
project-service.key=n/a
maven.maven-home=${MAVEN_HOME}
maven.console-logger=false
maven.local-repository=${RELEASE_TOOLS_MAVEN_REPOSITORY}
maven.parallelize=true
io.workDir=dist
io.workDir=${WORK_DIR}/dist
io.staging-dir=${WORK_DIR}/staging
io.logs=${LOGS_DIR}

View File

@@ -1,5 +1,6 @@
spring.main.banner-mode=off
io.work-dir=~/temp/spring-data-shell/workspace
io.staging-dir=~/temp/spring-data-shell/staging
io.logs=logs
# Maven setup
@@ -35,7 +36,6 @@ deployment.commercial.staging-repository=spring-enterprise-maven-stage-local
deployment.commercial.target-repository=spring-enterprise-maven-prod-local
deployment.commercial.distribution-repository=spring-enterprise-maven-prod-local
deployment.commercial.project=spring
deployment.maven-central.staging-profile-id=2a29ff48cbb4b
# deployment.commercial.distribution-repository=
# deployment.commercial.username <- local, for build

View File

@@ -0,0 +1,114 @@
/*
* Copyright 2025 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
*
* http://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 org.springframework.data.release.deployment;
import static org.assertj.core.api.Assertions.*;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.data.release.io.IoProperties;
import org.springframework.data.release.io.Workspace;
import org.springframework.data.release.model.Iteration;
import org.springframework.data.release.model.Password;
import org.springframework.data.release.model.ReleaseTrains;
import org.springframework.data.release.utils.Logger;
/**
* Tests for {@link MavenPublisher}.
*
* @author Mark Paluch
*/
class MavenPublisherTests {
@TempDir File stagingDir;
private MavenPublisher publisher;
@BeforeEach
void setUp() {
IoProperties io = new IoProperties();
io.setStagingDir(stagingDir.getAbsolutePath());
Workspace workspace = new Workspace(io, new Logger());
DeploymentProperties deploymentProperties = new DeploymentProperties();
DeploymentProperties.MavenCentral central = new DeploymentProperties.MavenCentral();
central.setUsername("foo");
central.setPassword(Password.of("bar"));
deploymentProperties.setMavenCentral(central);
publisher = new MavenPublisher(new Logger(), workspace, deploymentProperties, new RestTemplateBuilder());
}
@Test
void shouldInitializeEmptyStagingRepository() {
MavenPublisher.LocalStagingRepository stagingRepository = (MavenPublisher.LocalStagingRepository) publisher
.initializeStagingRepository();
assertThat(stagingRepository.getFile()).isEqualTo(new File(stagingDir, "central-staging"));
assertThat(stagingRepository.getFile()).exists();
}
@Test
void shouldPurgeStagingRepository() throws IOException {
File inner = new File(stagingDir, "central-staging/foo");
inner.mkdirs();
Files.write(new File(stagingDir, "central-staging.zip").toPath(), "foo".getBytes());
publisher.initializeStagingRepository();
assertThat(stagingDir.list()).hasSize(1).contains("central-staging");
assertThat(new File(stagingDir, "central-staging")).isEmptyDirectory();
}
@Test
void shouldCompressStagingContent() throws IOException {
MavenPublisher.LocalStagingRepository stagingRepository = (MavenPublisher.LocalStagingRepository) publisher
.initializeStagingRepository();
File directory = new File(publisher.getStagingDirectory(), "foo/bar");
directory.mkdirs();
Files.write(new File(directory, "baz.txt").toPath(), "foo".getBytes());
Files.write(new File(directory, ".DS_Store").toPath(), "foo".getBytes());
Files.write(new File(directory, "maven-metadata.xml").toPath(), "foo".getBytes());
Files.write(new File(directory, "maven-metadata.xml.md5").toPath(), "foo".getBytes());
publisher.compressStagedArtifacts(ReleaseTrains.Z.getIteration(Iteration.M1), stagingRepository);
ZipFile zf = new ZipFile(publisher.getStagingFile());
List<String> entries = Collections.list(zf.entries()).stream().map(ZipEntry::getName).collect(Collectors.toList());
zf.close();
assertThat(entries).hasSize(1).contains("foo/bar/baz.txt");
}
}