From c8e9d8e38db3488d984d72bb365a244bb7742b8d Mon Sep 17 00:00:00 2001 From: Mark Paluch Date: Fri, 6 Jun 2025 11:39:16 +0200 Subject: [PATCH] Add support for Maven Publisher API. --- ci/build-and-distribute.bash | 2 + smoke-tests/pom.xml | 6 +- smoke-tests/settings.xml | 12 + .../release/build/BuildConfiguration.java | 1 + .../data/release/build/BuildOperations.java | 191 ++++---- .../data/release/build/BuildSystem.java | 21 - .../data/release/build/MavenBuildSystem.java | 94 +--- .../data/release/cli/ReleaseCommands.java | 60 +-- .../deployment/DeploymentProperties.java | 19 +- .../release/deployment/MavenPublisher.java | 419 ++++++++++++++++++ .../release/deployment/StagingRepository.java | 32 +- .../data/release/io/IoProperties.java | 10 +- .../data/release/io/Workspace.java | 56 +-- .../release/utils/HttpBasicCredentials.java | 8 +- .../resources/application-jenkins.properties | 11 +- src/main/resources/application.properties | 2 +- .../deployment/MavenPublisherTests.java | 114 +++++ 17 files changed, 766 insertions(+), 292 deletions(-) create mode 100644 src/main/java/org/springframework/data/release/deployment/MavenPublisher.java create mode 100644 src/test/java/org/springframework/data/release/deployment/MavenPublisherTests.java 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"); + } +}