diff --git a/ci/build-and-distribute.bash b/ci/build-and-distribute.bash
index 024ab73..ca5a084 100755
--- a/ci/build-and-distribute.bash
+++ b/ci/build-and-distribute.bash
@@ -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}
diff --git a/smoke-tests/pom.xml b/smoke-tests/pom.xml
index 0b7ce12..8e75c11 100644
--- a/smoke-tests/pom.xml
+++ b/smoke-tests/pom.xml
@@ -18,7 +18,7 @@
2023.0.0-M1
- orgspringframework-2453
+ deploymentId
5.3.1
@@ -73,9 +73,9 @@
- s01.oss.sonatype.org
+ central-publisher-api
- https://s01.oss.sonatype.org/service/local/repositories/${stagingRepository}/content/
+ https://central.sonatype.com/api/v1/publisher/deployment/${deploymentId}/download/
diff --git a/smoke-tests/settings.xml b/smoke-tests/settings.xml
index d788021..76174fc 100644
--- a/smoke-tests/settings.xml
+++ b/smoke-tests/settings.xml
@@ -29,6 +29,18 @@
${env.COMMERCIAL_USR}
${env.COMMERCIAL_PSW}
+
+
+ central-publisher-api
+
+
+
+ Authorization
+ Bearer ${CENTRAL_BEARER}
+
+
+
+
diff --git a/src/main/java/org/springframework/data/release/build/BuildConfiguration.java b/src/main/java/org/springframework/data/release/build/BuildConfiguration.java
index 08e1b0b..c9d1f01 100644
--- a/src/main/java/org/springframework/data/release/build/BuildConfiguration.java
+++ b/src/main/java/org/springframework/data/release/build/BuildConfiguration.java
@@ -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;
diff --git a/src/main/java/org/springframework/data/release/build/BuildOperations.java b/src/main/java/org/springframework/data/release/build/BuildOperations.java
index ca8ed49..91e4079 100644
--- a/src/main/java/org/springframework/data/release/build/BuildOperations.java
+++ b/src/main/java/org/springframework/data/release/build/BuildOperations.java
@@ -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 of = Streamable.of(iteration.getModulesExcept(BOM, COMMONS, BUILD));
@@ -268,17 +188,14 @@ public class BuildOperations {
*/
public List performRelease(TrainIteration iteration) {
- StagingRepository stagingRepository = iteration.isPublic() //
- ? openStagingRepository(iteration) //
- : StagingRepository.EMPTY;
+ StagingRepository localStaging = iteration.isPublic() ? initializeStagingRepository() : StagingRepository.EMPTY;
+ StagingRepository stagingRepository = StagingRepository.EMPTY;
BuildExecutor.Summary 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 stageRelease(TrainIteration iteration) {
+
+ StagingRepository localStaging = initializeStagingRepository();
+
+ BuildExecutor.Summary 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 doWithBuildSystem(S module,
- BiFunction function) {
+ private T doWithBuildSystem(S module, BiFunction function) {
Assert.notNull(module, "ModuleIteration must not be null!");
diff --git a/src/main/java/org/springframework/data/release/build/BuildSystem.java b/src/main/java/org/springframework/data/release/build/BuildSystem.java
index ac2145b..c6cc211 100644
--- a/src/main/java/org/springframework/data/release/build/BuildSystem.java
+++ b/src/main/java/org/springframework/data/release/build/BuildSystem.java
@@ -69,27 +69,6 @@ interface BuildSystem extends Plugin {
*/
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 triggerBuild(M module);
M triggerDocumentationBuild(M module);
diff --git a/src/main/java/org/springframework/data/release/build/MavenBuildSystem.java b/src/main/java/org/springframework/data/release/build/MavenBuildSystem.java
index a2065dd..91b908c 100644
--- a/src/main/java/org/springframework/data/release/build/MavenBuildSystem.java
+++ b/src/main/java/org/springframework/data/release/build/MavenBuildSystem.java
@@ -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 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 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;
}
}
diff --git a/src/main/java/org/springframework/data/release/cli/ReleaseCommands.java b/src/main/java/org/springframework/data/release/cli/ReleaseCommands.java
index 5f2bd59..0d09f9a 100644
--- a/src/main/java/org/springframework/data/release/cli/ReleaseCommands.java
+++ b/src/main/java/org/springframework/data/release/cli/ReleaseCommands.java
@@ -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}.
*
diff --git a/src/main/java/org/springframework/data/release/deployment/DeploymentProperties.java b/src/main/java/org/springframework/data/release/deployment/DeploymentProperties.java
index b6cbf13..8678e53 100644
--- a/src/main/java/org/springframework/data/release/deployment/DeploymentProperties.java
+++ b/src/main/java/org/springframework/data/release/deployment/DeploymentProperties.java
@@ -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
diff --git a/src/main/java/org/springframework/data/release/deployment/MavenPublisher.java b/src/main/java/org/springframework/data/release/deployment/MavenPublisher.java
new file mode 100644
index 0000000..e204780
--- /dev/null
+++ b/src/main/java/org/springframework/data/release/deployment/MavenPublisher.java
@@ -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 body = new LinkedMultiValueMap<>();
+ body.add("bundle", new FileSystemResource(zipFile));
+
+ logger.log(iteration, "🚛 Uploading staging file …");
+ ResponseEntity 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 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 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() {
+
+ @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> errors = new LinkedHashMap<>();
+ private List purls = new ArrayList<>();
+
+ public String getErrorDetail() {
+
+ StringBuilder errorMessage = new StringBuilder();
+
+ errorMessage.append("\n\n").append("Deployment ").append(getDeploymentId()).append(" failed\n");
+
+ for (Map.Entry> 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);
+ }
+
+ }
+}
diff --git a/src/main/java/org/springframework/data/release/deployment/StagingRepository.java b/src/main/java/org/springframework/data/release/deployment/StagingRepository.java
index 58136e9..dc40a64 100644
--- a/src/main/java/org/springframework/data/release/deployment/StagingRepository.java
+++ b/src/main/java/org/springframework/data/release/deployment/StagingRepository.java
@@ -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);
+ }
+
}
diff --git a/src/main/java/org/springframework/data/release/io/IoProperties.java b/src/main/java/org/springframework/data/release/io/IoProperties.java
index da7d661..1a83aae 100644
--- a/src/main/java/org/springframework/data/release/io/IoProperties.java
+++ b/src/main/java/org/springframework/data/release/io/IoProperties.java
@@ -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()));
+ }
+
}
diff --git a/src/main/java/org/springframework/data/release/io/Workspace.java b/src/main/java/org/springframework/data/release/io/Workspace.java
index 3b635da..9e57c86 100644
--- a/src/main/java/org/springframework/data/release/io/Workspace.java
+++ b/src/main/java/org/springframework/data/release/io/Workspace.java
@@ -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() {
-
- @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 filter) throws IOException {
+ if (!path.toFile().exists()) {
+ return;
+ }
+
Files.walkFileTree(path, new SimpleFileVisitor() {
@Override
@@ -187,18 +179,6 @@ public class Workspace {
return new File(getProjectDirectory(project), name);
}
- public Stream 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);
diff --git a/src/main/java/org/springframework/data/release/utils/HttpBasicCredentials.java b/src/main/java/org/springframework/data/release/utils/HttpBasicCredentials.java
index ba487bc..018a5eb 100644
--- a/src/main/java/org/springframework/data/release/utils/HttpBasicCredentials.java
+++ b/src/main/java/org/springframework/data/release/utils/HttpBasicCredentials.java
@@ -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);
}
}
diff --git a/src/main/resources/application-jenkins.properties b/src/main/resources/application-jenkins.properties
index d39e757..f4f663a 100644
--- a/src/main/resources/application-jenkins.properties
+++ b/src/main/resources/application-jenkins.properties
@@ -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}
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
index e7a351f..f45f5ad 100644
--- a/src/main/resources/application.properties
+++ b/src/main/resources/application.properties
@@ -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
diff --git a/src/test/java/org/springframework/data/release/deployment/MavenPublisherTests.java b/src/test/java/org/springframework/data/release/deployment/MavenPublisherTests.java
new file mode 100644
index 0000000..b124e66
--- /dev/null
+++ b/src/test/java/org/springframework/data/release/deployment/MavenPublisherTests.java
@@ -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 entries = Collections.list(zf.entries()).stream().map(ZipEntry::getName).collect(Collectors.toList());
+ zf.close();
+
+ assertThat(entries).hasSize(1).contains("foo/bar/baz.txt");
+ }
+}