This is an automated email from the ASF dual-hosted git repository. royteeuwen pushed a commit to branch feature/sling-cli-release-automation in repository https://gitbox.apache.org/repos/asf/sling-org-apache-sling-committer-cli.git
commit 8a1e1055c69f4e9f141e1eb77ab03cab158c87b0 Author: Roy Teeuwen <[email protected]> AuthorDate: Sat May 30 08:09:57 2026 +0200 release finalize/update-dist: one-step finalization, PMC auto-detected Add `finalize`, which runs the post-vote steps in sequence (promote to Maven Central, update dist.apache.org, create the next JIRA version and move unresolved issues, mark the JIRA version released, update the Apache Reporter), and `update-dist`, which moves artifacts from dist/dev to dist/release. PMC membership is detected automatically from the current ASF user, so the dist.apache.org step runs for PMC members and is skipped otherwise (no flag needed). The previous release to remove from dist/release is deduced from the directory contents, with an optional --previous-version override on update-dist. The Dockerfile bundles subversion, required by the svnmucc-based dist update. --- Dockerfile | 3 + .../sling/cli/impl/release/FinalizeCommand.java | 268 +++++++++++++++++++++ .../sling/cli/impl/release/UpdateDistCommand.java | 237 ++++++++++++++++++ .../cli/impl/release/FinalizeCommandTest.java | 187 ++++++++++++++ .../cli/impl/release/UpdateDistCommandTest.java | 208 ++++++++++++++++ 5 files changed, 903 insertions(+) diff --git a/Dockerfile b/Dockerfile index d863988..4e8cd5e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,6 +22,9 @@ LABEL org.opencontainers.image.licenses="Apache-2.0" COPY --from=builder /opt/jre /opt/jre +# subversion provides svn and svnmucc, used by release update-dist (PMC members only) +RUN apk add --no-cache subversion + # Generate class data sharing RUN /opt/jre/bin/java -Xshare:dump diff --git a/src/main/java/org/apache/sling/cli/impl/release/FinalizeCommand.java b/src/main/java/org/apache/sling/cli/impl/release/FinalizeCommand.java new file mode 100644 index 0000000..b0ad81a --- /dev/null +++ b/src/main/java/org/apache/sling/cli/impl/release/FinalizeCommand.java @@ -0,0 +1,268 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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.apache.sling.cli.impl.release; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Set; + +import org.apache.http.NameValuePair; +import org.apache.http.client.entity.UrlEncodedFormEntity; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.message.BasicNameValuePair; +import org.apache.sling.cli.impl.Command; +import org.apache.sling.cli.impl.Credentials; +import org.apache.sling.cli.impl.CredentialsService; +import org.apache.sling.cli.impl.ExecutionMode; +import org.apache.sling.cli.impl.http.HttpClientFactory; +import org.apache.sling.cli.impl.jira.Issue; +import org.apache.sling.cli.impl.jira.Version; +import org.apache.sling.cli.impl.jira.VersionClient; +import org.apache.sling.cli.impl.nexus.Artifact; +import org.apache.sling.cli.impl.nexus.RepositoryService; +import org.apache.sling.cli.impl.nexus.StagingRepository; +import org.apache.sling.cli.impl.people.MembersFinder; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import picocli.CommandLine; + +/** + * Runs all post-vote release finalization steps in sequence: + * <ol> + * <li>Promote staging repository to Maven Central</li> + * <li>Upload artifacts to dist.apache.org (only when the current user is a PMC member)</li> + * <li>Create the next JIRA version and move unresolved issues</li> + * <li>Mark the current JIRA version as released</li> + * <li>Update the Apache Reporter System</li> + * </ol> + * Uploading to dist.apache.org is only possible for PMC members, so it is performed automatically + * when the current user (resolved from the ASF credentials) is a PMC member and skipped otherwise. + * When skipped, a PMC member must complete it separately (the {@code tally-votes} result email asks + * for this when run by a non-PMC member). The previous version to remove from dist/release is + * deduced from the current contents of the release directory. + */ +@Component( + service = Command.class, + property = { + Command.PROPERTY_NAME_COMMAND_GROUP + "=" + FinalizeCommand.GROUP, + Command.PROPERTY_NAME_COMMAND_NAME + "=" + FinalizeCommand.NAME + }) [email protected]( + name = FinalizeCommand.NAME, + description = + "Runs all post-vote finalization steps: promote to Maven Central, update JIRA, and report to Apache." + + " When the current user is a PMC member, dist.apache.org is updated as well.", + subcommands = CommandLine.HelpCommand.class) +public class FinalizeCommand implements Command { + + static final String GROUP = "release"; + static final String NAME = "finalize"; + + private static final Logger LOGGER = LoggerFactory.getLogger(FinalizeCommand.class); + + @CommandLine.Option( + names = {"-r", "--repository"}, + description = "Nexus staging repository id", + required = true) + private Integer repositoryId; + + @CommandLine.Mixin + private ReusableCLIOptions reusableCLIOptions; + + @Reference + private RepositoryService repositoryService; + + @Reference + private VersionClient versionClient; + + @Reference + private HttpClientFactory httpClientFactory; + + @Reference + private CredentialsService credentialsService; + + @Reference + private MembersFinder membersFinder; + + @Override + public Integer call() { + try { + StagingRepository repository = repositoryService.find(repositoryId); + Set<Release> releases = repositoryService.getReleases(repository); + ExecutionMode mode = reusableCLIOptions.executionMode; + boolean isPmcMember = membersFinder.getCurrentMember().isPMCMember(); + + String releaseNames = releases.stream() + .map(Release::getFullName) + .reduce((a, b) -> a + ", " + b) + .orElse("(none)"); + LOGGER.info("=== Finalizing: {} ===", releaseNames); + + // Step 1: Promote to Maven Central + LOGGER.info("--- Step 1/5: Promote to Maven Central ---"); + if (mode == ExecutionMode.DRY_RUN) { + LOGGER.info("Would promote {} to Maven Central", repository.getRepositoryId()); + } else { + LOGGER.info("Promoting {}...", repository.getRepositoryId()); + repositoryService.promote(repository); + LOGGER.info("Promoted. Artifacts will appear on Maven Central within ~10 minutes."); + } + + // Step 2: Update dist.apache.org (PMC members only; auto-detected) + if (isPmcMember) { + LOGGER.info("--- Step 2/5: Update dist.apache.org ---"); + stepUpdateDist(repository, mode); + } else { + LOGGER.info("--- Step 2/5: Update dist.apache.org --- SKIPPED (current user is not a PMC member;" + + " a PMC member must update dist.apache.org separately) ---"); + } + + // Step 3: Create next JIRA version and move unresolved issues + LOGGER.info("--- Step 3/5: Create next JIRA version ---"); + for (Release release : releases) { + stepCreateNextJiraVersion(release, mode); + } + + // Step 4: Mark JIRA version as released + LOGGER.info("--- Step 4/5: Release JIRA version ---"); + for (Release release : releases) { + stepReleaseJiraVersion(release, mode); + } + + // Step 5: Update Apache Reporter + LOGGER.info("--- Step 5/5: Update Apache Reporter ---"); + if (mode == ExecutionMode.DRY_RUN) { + LOGGER.info("Would add {} release(s) to the Apache Reporter System", releases.size()); + releases.forEach(r -> LOGGER.info(" - {}", r.getFullName())); + } else { + stepUpdateReporter(releases); + } + + LOGGER.info("=== Release finalization complete! ==="); + } catch (Exception e) { + LOGGER.warn("Failed executing command", e); + return CommandLine.ExitCode.SOFTWARE; + } + return CommandLine.ExitCode.OK; + } + + private void stepUpdateDist(StagingRepository repository, ExecutionMode mode) + throws IOException, InterruptedException { + Set<Artifact> artifacts = repositoryService.getArtifacts(repository); + Artifact primary = artifacts.stream() + .filter(a -> "pom".equals(a.getType())) + .findFirst() + .orElseThrow(() -> new IllegalStateException("No POM artifact found")); + + String artifactId = primary.getArtifactId(); + String newVersion = primary.getVersion(); + + List<String> newFiles = + UpdateDistCommand.listSvnFiles(UpdateDistCommand.DIST_DEV_URL, artifactId + "-" + newVersion); + // The previous version to remove is deduced from the current dist/release contents. + List<String> oldFiles = UpdateDistCommand.listPreviousReleaseFiles(artifactId, newVersion, null); + + if (newFiles.isEmpty()) { + LOGGER.warn("No files found in dist/dev matching {}-{}; skipping dist update.", artifactId, newVersion); + LOGGER.warn("Run 'release update-dist' manually after staging dist/dev artifacts."); + return; + } + + if (mode == ExecutionMode.DRY_RUN) { + LOGGER.info("Would move {} file(s) from dist/dev to dist/release", newFiles.size()); + newFiles.forEach(f -> LOGGER.info(" mv dev/{} -> release/{}", f, f)); + LOGGER.info("Would remove {} old file(s) from dist/release", oldFiles.size()); + oldFiles.forEach(f -> LOGGER.info(" rm release/{}", f)); + } else { + Credentials creds = credentialsService.getAsfCredentials(); + UpdateDistCommand.runSvnMucc(artifactId, newVersion, newFiles, oldFiles, creds); + } + } + + private void stepCreateNextJiraVersion(Release release, ExecutionMode mode) throws IOException { + Version successorVersion = versionClient.findSuccessorVersion(release); + if (successorVersion == null) { + Release next = release.next(); + if (mode == ExecutionMode.DRY_RUN) { + LOGGER.info("Would create JIRA version {}", next.getName()); + } else { + versionClient.create(next.getName()); + LOGGER.info("Created JIRA version {}", next.getName()); + successorVersion = versionClient.findSuccessorVersion(release); + } + } else { + LOGGER.info("Successor JIRA version {} already exists", successorVersion.getName()); + } + if (successorVersion != null) { + List<Issue> unresolved = versionClient.findUnresolvedIssues(release); + if (!unresolved.isEmpty()) { + if (mode == ExecutionMode.DRY_RUN) { + LOGGER.info( + "Would move {} unresolved issue(s) from {} to {}", + unresolved.size(), + release.getName(), + successorVersion.getName()); + } else { + versionClient.moveIssuesToNewVersion(versionClient.find(release), successorVersion, unresolved); + LOGGER.info("Moved {} unresolved issue(s) to {}", unresolved.size(), successorVersion.getName()); + } + } + } + } + + private void stepReleaseJiraVersion(Release release, ExecutionMode mode) throws Exception { + if (mode == ExecutionMode.DRY_RUN) { + LOGGER.info("Would mark JIRA version {} as released", release.getFullName()); + } else { + versionClient.release(release); + LOGGER.info("Marked JIRA version {} as released", release.getFullName()); + } + } + + private void stepUpdateReporter(Set<Release> releases) throws IOException { + try (CloseableHttpClient client = httpClientFactory.newClient()) { + SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd"); + Date now = new Date(); + for (Release release : releases) { + HttpPost post = new HttpPost("https://reporter.apache.org/addrelease.py"); + List<NameValuePair> params = new ArrayList<>(); + params.add(new BasicNameValuePair("date", Long.toString(now.getTime() / 1000))); + params.add(new BasicNameValuePair("committee", "sling")); + params.add(new BasicNameValuePair("version", release.getFullName())); + params.add(new BasicNameValuePair("xdate", sdf.format(now))); + post.setEntity(new UrlEncodedFormEntity(params, StandardCharsets.UTF_8)); + try (CloseableHttpResponse response = client.execute(post)) { + if (response.getStatusLine().getStatusCode() != 200) { + throw new IOException("Reporter update failed for " + release.getFullName() + ": HTTP " + + response.getStatusLine().getStatusCode()); + } + } + LOGGER.info("Updated Apache Reporter for {}", release.getFullName()); + } + } + } +} diff --git a/src/main/java/org/apache/sling/cli/impl/release/UpdateDistCommand.java b/src/main/java/org/apache/sling/cli/impl/release/UpdateDistCommand.java new file mode 100644 index 0000000..903364e --- /dev/null +++ b/src/main/java/org/apache/sling/cli/impl/release/UpdateDistCommand.java @@ -0,0 +1,237 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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.apache.sling.cli.impl.release; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +import org.apache.sling.cli.impl.Command; +import org.apache.sling.cli.impl.Credentials; +import org.apache.sling.cli.impl.CredentialsService; +import org.apache.sling.cli.impl.InputOption; +import org.apache.sling.cli.impl.UserInput; +import org.apache.sling.cli.impl.nexus.Artifact; +import org.apache.sling.cli.impl.nexus.RepositoryService; +import org.apache.sling.cli.impl.nexus.StagingRepository; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import picocli.CommandLine; + +/** + * Moves release artifacts from dist/dev to dist/release on dist.apache.org and removes the + * previous release. Uses svnmucc for atomic SVN operations. + * Requires PMC membership to commit to dist.apache.org. + */ +@Component( + service = Command.class, + property = { + Command.PROPERTY_NAME_COMMAND_GROUP + "=" + UpdateDistCommand.GROUP, + Command.PROPERTY_NAME_COMMAND_NAME + "=" + UpdateDistCommand.NAME + }) [email protected]( + name = UpdateDistCommand.NAME, + description = "Moves release artifacts from dist/dev to dist/release on dist.apache.org and removes the" + + " previous release. Requires PMC membership.", + subcommands = CommandLine.HelpCommand.class) +public class UpdateDistCommand implements Command { + + static final String GROUP = "release"; + static final String NAME = "update-dist"; + + static final String DIST_DEV_URL = "https://dist.apache.org/repos/dist/dev/sling/"; + static final String DIST_RELEASE_URL = "https://dist.apache.org/repos/dist/release/sling/"; + + private static final Logger LOGGER = LoggerFactory.getLogger(UpdateDistCommand.class); + + @CommandLine.Option( + names = {"-r", "--repository"}, + description = "Nexus staging repository id", + required = true) + private Integer repositoryId; + + @CommandLine.Option( + names = {"--previous-version"}, + description = "Previous release version to remove from dist/release (e.g. 1.0.0)." + + " Optional: if omitted, all older versions currently in dist/release are removed.") + private String previousVersion; + + @CommandLine.Mixin + private ReusableCLIOptions reusableCLIOptions; + + @Reference + private RepositoryService repositoryService; + + @Reference + private CredentialsService credentialsService; + + @Override + public Integer call() { + try { + StagingRepository repository = repositoryService.find(repositoryId); + Set<Artifact> artifacts = repositoryService.getArtifacts(repository); + Artifact primary = artifacts.stream() + .filter(a -> "pom".equals(a.getType())) + .findFirst() + .orElseThrow(() -> new IllegalStateException("No POM artifact found in staging repository")); + String artifactId = primary.getArtifactId(); + String newVersion = primary.getVersion(); + + List<String> newFiles = listSvnFiles(DIST_DEV_URL, artifactId + "-" + newVersion); + List<String> oldFiles = listPreviousReleaseFiles(artifactId, newVersion, previousVersion); + + if (newFiles.isEmpty()) { + LOGGER.warn("No files found in {} matching {}-{}", DIST_DEV_URL, artifactId, newVersion); + LOGGER.warn("Ensure 'mvn release:perform' has staged source-release artifacts to dist/dev."); + return CommandLine.ExitCode.USAGE; + } + + switch (reusableCLIOptions.executionMode) { + case DRY_RUN: + LOGGER.info("Would move {} file(s) from dist/dev to dist/release:", newFiles.size()); + newFiles.forEach(f -> LOGGER.info(" mv {} -> {}", DIST_DEV_URL + f, DIST_RELEASE_URL + f)); + if (!oldFiles.isEmpty()) { + LOGGER.info("Would remove {} old file(s) from dist/release:", oldFiles.size()); + oldFiles.forEach(f -> LOGGER.info(" rm {}", DIST_RELEASE_URL + f)); + } + break; + case INTERACTIVE: + String question = String.format( + "Move %d file(s) for %s %s to dist/release and remove %d older file(s) for %s?", + newFiles.size(), artifactId, newVersion, oldFiles.size(), artifactId); + InputOption answer = UserInput.yesNo(question, InputOption.YES); + if (InputOption.YES.equals(answer)) { + runSvnMucc(artifactId, newVersion, newFiles, oldFiles, credentialsService.getAsfCredentials()); + } else { + LOGGER.info("Aborted."); + } + break; + case AUTO: + runSvnMucc(artifactId, newVersion, newFiles, oldFiles, credentialsService.getAsfCredentials()); + break; + } + } catch (IOException | InterruptedException e) { + if (e instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } + LOGGER.warn("Failed executing command", e); + return CommandLine.ExitCode.SOFTWARE; + } + return CommandLine.ExitCode.OK; + } + + static void runSvnMucc( + String artifactId, String newVersion, List<String> newFiles, List<String> oldFiles, Credentials credentials) + throws IOException, InterruptedException { + Logger logger = LoggerFactory.getLogger(UpdateDistCommand.class); + + List<String> cmd = new ArrayList<>(); + cmd.add("svnmucc"); + cmd.add("--username"); + cmd.add(credentials.getUsername()); + cmd.add("--password"); + cmd.add(credentials.getPassword()); + cmd.add("--non-interactive"); + cmd.add("-m"); + cmd.add("Release " + artifactId + " " + newVersion); + + for (String file : newFiles) { + cmd.add("mv"); + cmd.add(DIST_DEV_URL + file); + cmd.add(DIST_RELEASE_URL + file); + } + for (String file : oldFiles) { + cmd.add("rm"); + cmd.add(DIST_RELEASE_URL + file); + } + + logger.info("Running svnmucc to update dist.apache.org..."); + int exitCode = new ProcessBuilder(cmd).inheritIO().start().waitFor(); + if (exitCode != 0) { + throw new IOException("svnmucc failed with exit code " + exitCode); + } + logger.info("Done. dist.apache.org has been updated."); + } + + /** + * Determines which files to remove from {@code dist/release} when publishing {@code newVersion} + * of {@code artifactId}. When {@code explicitPreviousVersion} is given, only that version's files + * are returned; otherwise every older version currently present in {@code dist/release} for this + * artifact is returned (the release directory holds only the latest release per ASF policy). + */ + static List<String> listPreviousReleaseFiles(String artifactId, String newVersion, String explicitPreviousVersion) + throws IOException { + if (explicitPreviousVersion != null && !explicitPreviousVersion.isBlank()) { + return listSvnFiles(DIST_RELEASE_URL, artifactId + "-" + explicitPreviousVersion); + } + return listSvnFiles(DIST_RELEASE_URL, artifactId + "-").stream() + // keep only versioned files for this exact artifact (a numeric version component + // right after the prefix excludes sibling artifacts such as "<artifactId>-extra-...") + .filter(f -> isVersionedArtifactFile(f, artifactId)) + // never remove the version we are about to publish + .filter(f -> !belongsToVersion(f, artifactId, newVersion)) + .collect(java.util.stream.Collectors.toList()); + } + + private static boolean isVersionedArtifactFile(String fileName, String artifactId) { + String prefix = artifactId + "-"; + return fileName.length() > prefix.length() + && fileName.startsWith(prefix) + && Character.isDigit(fileName.charAt(prefix.length())); + } + + private static boolean belongsToVersion(String fileName, String artifactId, String version) { + String prefix = artifactId + "-" + version; + if (!fileName.startsWith(prefix)) { + return false; + } + // the version is followed by an extension ('.') or a classifier ('-'), or is the whole name; + // this avoids matching e.g. 1.0.14 against 1.0.140 + if (fileName.length() == prefix.length()) { + return true; + } + char next = fileName.charAt(prefix.length()); + return next == '.' || next == '-'; + } + + static List<String> listSvnFiles(String baseUrl, String prefix) throws IOException { + List<String> files = new ArrayList<>(); + try { + ProcessBuilder pb = new ProcessBuilder("svn", "list", "--non-interactive", baseUrl); + pb.redirectErrorStream(true); + Process p = pb.start(); + try (BufferedReader reader = new BufferedReader(new InputStreamReader(p.getInputStream()))) { + reader.lines() + .map(String::trim) + .filter(l -> l.startsWith(prefix)) + .forEach(files::add); + } + p.waitFor(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted while listing SVN directory", e); + } + return files; + } +} diff --git a/src/test/java/org/apache/sling/cli/impl/release/FinalizeCommandTest.java b/src/test/java/org/apache/sling/cli/impl/release/FinalizeCommandTest.java new file mode 100644 index 0000000..5298d61 --- /dev/null +++ b/src/test/java/org/apache/sling/cli/impl/release/FinalizeCommandTest.java @@ -0,0 +1,187 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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.apache.sling.cli.impl.release; + +import java.util.List; +import java.util.Set; + +import org.apache.commons.lang3.reflect.FieldUtils; +import org.apache.http.StatusLine; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.sling.cli.impl.Command; +import org.apache.sling.cli.impl.Credentials; +import org.apache.sling.cli.impl.CredentialsService; +import org.apache.sling.cli.impl.ExecutionMode; +import org.apache.sling.cli.impl.http.HttpClientFactory; +import org.apache.sling.cli.impl.jira.VersionClient; +import org.apache.sling.cli.impl.junit.LogCapture; +import org.apache.sling.cli.impl.nexus.Artifact; +import org.apache.sling.cli.impl.nexus.RepositoryService; +import org.apache.sling.cli.impl.nexus.StagingRepository; +import org.apache.sling.cli.impl.people.Member; +import org.apache.sling.cli.impl.people.MembersFinder; +import org.apache.sling.testing.mock.osgi.junit.OsgiContext; +import org.junit.Rule; +import org.junit.Test; +import org.mockito.MockedStatic; +import picocli.CommandLine; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class FinalizeCommandTest { + + @Rule + public final OsgiContext osgiContext = new OsgiContext(); + + @Rule + public final LogCapture logCapture = new LogCapture(FinalizeCommand.class); + + private RepositoryService repositoryService; + private VersionClient versionClient; + private CloseableHttpClient client; + + /** + * Sets up all collaborators. {@code pmcMember} controls whether the current user is detected as + * a PMC member, which drives the dist.apache.org step. + */ + private void prepare(boolean pmcMember) throws Exception { + StagingRepository stagingRepository = mock(StagingRepository.class); + when(stagingRepository.getRepositoryId()).thenReturn("orgapachesling-123"); + when(stagingRepository.getDescription()).thenReturn("Apache Sling CLI Test 1.0.0"); + + repositoryService = mock(RepositoryService.class); + when(repositoryService.find(123)).thenReturn(stagingRepository); + Set<Release> releases = + Set.of(Release.fromString("Apache Sling CLI Test 1.0.0").get(0)); + when(repositoryService.getReleases(stagingRepository)).thenReturn(releases); + Artifact pom = + new Artifact(stagingRepository, "org.apache.sling", "org.apache.sling.cli.test", "1.0.0", null, "pom"); + when(repositoryService.getArtifacts(stagingRepository)).thenReturn(Set.of(pom)); + + // Step 3/4 - keep JIRA interactions trivial: a successor version already exists and there are + // no unresolved issues, so nothing is created or moved. + versionClient = mock(VersionClient.class); + when(versionClient.findSuccessorVersion(any())).thenReturn(mock(org.apache.sling.cli.impl.jira.Version.class)); + when(versionClient.findUnresolvedIssues(any())).thenReturn(List.of()); + + // Step 5 - the reporter HTTP POST returns 200. + HttpClientFactory httpClientFactory = mock(HttpClientFactory.class); + client = mock(CloseableHttpClient.class); + when(httpClientFactory.newClient()).thenReturn(client); + CloseableHttpResponse response = mock(CloseableHttpResponse.class); + StatusLine statusLine = mock(StatusLine.class); + when(statusLine.getStatusCode()).thenReturn(200); + when(response.getStatusLine()).thenReturn(statusLine); + when(client.execute(any())).thenReturn(response); + + CredentialsService credentialsService = mock(CredentialsService.class); + when(credentialsService.getAsfCredentials()).thenReturn(new Credentials("johndoe", "secret")); + + MembersFinder membersFinder = mock(MembersFinder.class); + when(membersFinder.getCurrentMember()).thenReturn(new Member("johndoe", "John Doe", pmcMember)); + + osgiContext.registerService(RepositoryService.class, repositoryService); + osgiContext.registerService(VersionClient.class, versionClient); + osgiContext.registerService(HttpClientFactory.class, httpClientFactory); + osgiContext.registerService(CredentialsService.class, credentialsService); + osgiContext.registerService(MembersFinder.class, membersFinder); + } + + @Test + public void testDryRunNonPmc() throws Exception { + prepare(false); + Command command = createCommand(123, ExecutionMode.DRY_RUN); + assertEquals(CommandLine.ExitCode.OK, (int) command.call()); + assertTrue(logCapture.containsMessage("--- Step 2/5: Update dist.apache.org --- SKIPPED (current user is not a" + + " PMC member; a PMC member must update dist.apache.org separately) ---")); + // dry-run: nothing is actually promoted + verify(repositoryService, never()).promote(any()); + } + + @Test + public void testDryRunPmc() throws Exception { + prepare(true); + try (MockedStatic<UpdateDistCommand> dist = mockStatic(UpdateDistCommand.class)) { + dist.when(() -> UpdateDistCommand.listSvnFiles(any(), any())) + .thenReturn(List.of("org.apache.sling.cli.test-1.0.0.pom")); + dist.when(() -> UpdateDistCommand.listPreviousReleaseFiles(any(), any(), any())) + .thenReturn(List.of("org.apache.sling.cli.test-0.9.0.pom")); + Command command = createCommand(123, ExecutionMode.DRY_RUN); + assertEquals(CommandLine.ExitCode.OK, (int) command.call()); + assertTrue(logCapture.containsMessage("--- Step 2/5: Update dist.apache.org ---")); + // dry-run: dist is described, not committed + dist.verify(() -> UpdateDistCommand.runSvnMucc(any(), any(), any(), any(), any()), never()); + } + } + + @Test + public void testAutoNonPmc() throws Exception { + prepare(false); + Command command = createCommand(123, ExecutionMode.AUTO); + assertEquals(CommandLine.ExitCode.OK, (int) command.call()); + // promoted to Maven Central, dist skipped, jira released, reporter updated + verify(repositoryService).promote(any()); + verify(versionClient).release(any()); + verify(client).execute(any()); + assertTrue(logCapture.containsMessage("--- Step 2/5: Update dist.apache.org --- SKIPPED (current user is not a" + + " PMC member; a PMC member must update dist.apache.org separately) ---")); + } + + @Test + public void testAutoPmc() throws Exception { + prepare(true); + try (MockedStatic<UpdateDistCommand> dist = mockStatic(UpdateDistCommand.class)) { + dist.when(() -> UpdateDistCommand.listSvnFiles(any(), any())) + .thenReturn(List.of("org.apache.sling.cli.test-1.0.0.pom")); + dist.when(() -> UpdateDistCommand.listPreviousReleaseFiles(any(), any(), any())) + .thenReturn(List.of("org.apache.sling.cli.test-0.9.0.pom")); + Command command = createCommand(123, ExecutionMode.AUTO); + assertEquals(CommandLine.ExitCode.OK, (int) command.call()); + verify(repositoryService).promote(any()); + // dist upload is actually committed for a PMC member + dist.verify(() -> + UpdateDistCommand.runSvnMucc(eq("org.apache.sling.cli.test"), eq("1.0.0"), any(), any(), any())); + verify(versionClient).release(any()); + } + } + + private Command createCommand(int repositoryId, ExecutionMode executionMode) throws IllegalAccessException { + FinalizeCommand finalizeCommand = spy(new FinalizeCommand()); + FieldUtils.writeField(finalizeCommand, "repositoryId", repositoryId, true); + ReusableCLIOptions reusableCLIOptions = mock(ReusableCLIOptions.class); + FieldUtils.writeField(reusableCLIOptions, "executionMode", executionMode, true); + FieldUtils.writeField(finalizeCommand, "reusableCLIOptions", reusableCLIOptions, true); + osgiContext.registerInjectActivateService(finalizeCommand); + Command result = osgiContext.getService(Command.class); + assertTrue( + "Expected to retrieve the FinalizeCommand from the mocked OSGi environment.", + result instanceof FinalizeCommand); + return result; + } +} diff --git a/src/test/java/org/apache/sling/cli/impl/release/UpdateDistCommandTest.java b/src/test/java/org/apache/sling/cli/impl/release/UpdateDistCommandTest.java new file mode 100644 index 0000000..1457a5c --- /dev/null +++ b/src/test/java/org/apache/sling/cli/impl/release/UpdateDistCommandTest.java @@ -0,0 +1,208 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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.apache.sling.cli.impl.release; + +import java.util.List; +import java.util.Set; + +import org.apache.commons.lang3.reflect.FieldUtils; +import org.apache.sling.cli.impl.Command; +import org.apache.sling.cli.impl.Credentials; +import org.apache.sling.cli.impl.CredentialsService; +import org.apache.sling.cli.impl.ExecutionMode; +import org.apache.sling.cli.impl.junit.LogCapture; +import org.apache.sling.cli.impl.nexus.Artifact; +import org.apache.sling.cli.impl.nexus.RepositoryService; +import org.apache.sling.cli.impl.nexus.StagingRepository; +import org.apache.sling.testing.mock.osgi.junit.OsgiContext; +import org.junit.Rule; +import org.junit.Test; +import org.mockito.MockedStatic; +import picocli.CommandLine; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.when; + +public class UpdateDistCommandTest { + + private static final String ARTIFACT = "org.apache.sling.feature.launcher"; + + @Rule + public final OsgiContext osgiContext = new OsgiContext(); + + @Rule + public final LogCapture logCapture = new LogCapture(UpdateDistCommand.class); + + // ---- auto-deduce of the previous release files (listPreviousReleaseFiles) ---- + + @Test + public void testAutoDeducePreviousFilesExcludesNewVersionAndSiblings() throws Exception { + List<String> releaseDir = List.of( + ARTIFACT + "-1.3.4.pom", + ARTIFACT + "-1.3.4.pom.asc", + ARTIFACT + "-1.3.4-source-release.zip", + ARTIFACT + "-1.3.4-source-release.zip.asc", + ARTIFACT + "-1.3.6.pom", // the new version - must be kept (not removed) + ARTIFACT + "-1.3.6.pom.asc", + ARTIFACT + "-extra-1.0.0.pom" // a sibling artifact - must be ignored + ); + try (MockedStatic<UpdateDistCommand> dist = mockStatic(UpdateDistCommand.class, CALLS_REAL_METHODS)) { + dist.when(() -> UpdateDistCommand.listSvnFiles(eq(UpdateDistCommand.DIST_RELEASE_URL), anyString())) + .thenReturn(releaseDir); + + List<String> old = UpdateDistCommand.listPreviousReleaseFiles(ARTIFACT, "1.3.6", null); + + assertEquals(4, old.size()); + assertTrue(old.contains(ARTIFACT + "-1.3.4.pom")); + assertTrue(old.contains(ARTIFACT + "-1.3.4-source-release.zip")); + // the version being published is never removed + assertFalse(old.contains(ARTIFACT + "-1.3.6.pom")); + assertFalse(old.contains(ARTIFACT + "-1.3.6.pom.asc")); + // sibling artifact with a non-numeric component is ignored + assertFalse(old.contains(ARTIFACT + "-extra-1.0.0.pom")); + } + } + + @Test + public void testAutoDeduceDoesNotConfuseVersionPrefixes() throws Exception { + // publishing 1.0.14 must not treat 1.0.140 as the same version + List<String> releaseDir = List.of(ARTIFACT + "-1.0.140.pom", ARTIFACT + "-1.0.14.pom"); + try (MockedStatic<UpdateDistCommand> dist = mockStatic(UpdateDistCommand.class, CALLS_REAL_METHODS)) { + dist.when(() -> UpdateDistCommand.listSvnFiles(eq(UpdateDistCommand.DIST_RELEASE_URL), anyString())) + .thenReturn(releaseDir); + + List<String> old = UpdateDistCommand.listPreviousReleaseFiles(ARTIFACT, "1.0.14", null); + + // 1.0.140 is a different version and is removed; 1.0.14 (being published) is kept + assertEquals(List.of(ARTIFACT + "-1.0.140.pom"), old); + } + } + + @Test + public void testExplicitPreviousVersionWins() throws Exception { + try (MockedStatic<UpdateDistCommand> dist = mockStatic(UpdateDistCommand.class, CALLS_REAL_METHODS)) { + dist.when(() -> UpdateDistCommand.listSvnFiles( + eq(UpdateDistCommand.DIST_RELEASE_URL), eq(ARTIFACT + "-1.3.4"))) + .thenReturn(List.of(ARTIFACT + "-1.3.4.pom")); + + List<String> old = UpdateDistCommand.listPreviousReleaseFiles(ARTIFACT, "1.3.6", "1.3.4"); + + assertEquals(List.of(ARTIFACT + "-1.3.4.pom"), old); + // when an explicit version is given, the directory is not enumerated with the bare prefix + dist.verify( + () -> UpdateDistCommand.listSvnFiles(eq(UpdateDistCommand.DIST_RELEASE_URL), eq(ARTIFACT + "-")), + never()); + } + } + + // ---- full command flow ---- + + @Test + public void testDryRunDescribesMoveAndRemoveWithoutCommitting() throws Exception { + prepareRepositoryService(); + try (MockedStatic<UpdateDistCommand> dist = mockStatic(UpdateDistCommand.class, CALLS_REAL_METHODS)) { + dist.when(() -> UpdateDistCommand.listSvnFiles(eq(UpdateDistCommand.DIST_DEV_URL), anyString())) + .thenReturn(List.of(ARTIFACT + "-1.3.6-source-release.zip")); + dist.when(() -> UpdateDistCommand.listSvnFiles(eq(UpdateDistCommand.DIST_RELEASE_URL), anyString())) + .thenReturn(List.of(ARTIFACT + "-1.3.4.pom")); + + Command command = createCommand(ExecutionMode.DRY_RUN, null); + assertEquals(CommandLine.ExitCode.OK, (int) command.call()); + + assertTrue(logCapture.containsMessage("Would move 1 file(s) from dist/dev to dist/release:")); + assertTrue(logCapture.containsMessage("Would remove 1 old file(s) from dist/release:")); + dist.verify(() -> UpdateDistCommand.runSvnMucc(any(), any(), any(), any(), any()), never()); + } + } + + @Test + public void testAutoCommitsViaSvnMucc() throws Exception { + prepareRepositoryService(); + try (MockedStatic<UpdateDistCommand> dist = mockStatic(UpdateDistCommand.class, CALLS_REAL_METHODS)) { + dist.when(() -> UpdateDistCommand.listSvnFiles(eq(UpdateDistCommand.DIST_DEV_URL), anyString())) + .thenReturn(List.of(ARTIFACT + "-1.3.6-source-release.zip")); + dist.when(() -> UpdateDistCommand.listSvnFiles(eq(UpdateDistCommand.DIST_RELEASE_URL), anyString())) + .thenReturn(List.of(ARTIFACT + "-1.3.4.pom")); + // do not actually shell out to svnmucc + dist.when(() -> UpdateDistCommand.runSvnMucc(any(), any(), any(), any(), any())) + .thenAnswer(invocation -> null); + + Command command = createCommand(ExecutionMode.AUTO, null); + assertEquals(CommandLine.ExitCode.OK, (int) command.call()); + + dist.verify(() -> UpdateDistCommand.runSvnMucc(eq(ARTIFACT), eq("1.3.6"), any(), any(), any())); + } + } + + @Test + public void testNoNewFilesReturnsUsage() throws Exception { + prepareRepositoryService(); + try (MockedStatic<UpdateDistCommand> dist = mockStatic(UpdateDistCommand.class, CALLS_REAL_METHODS)) { + dist.when(() -> UpdateDistCommand.listSvnFiles(eq(UpdateDistCommand.DIST_DEV_URL), anyString())) + .thenReturn(List.of()); + // the release directory listing is computed before the empty-new-files check; stub it so + // the real `svn` is never invoked + dist.when(() -> UpdateDistCommand.listSvnFiles(eq(UpdateDistCommand.DIST_RELEASE_URL), anyString())) + .thenReturn(List.of()); + + Command command = createCommand(ExecutionMode.AUTO, null); + assertEquals(CommandLine.ExitCode.USAGE, (int) command.call()); + dist.verify(() -> UpdateDistCommand.runSvnMucc(any(), any(), any(), any(), any()), never()); + } + } + + private void prepareRepositoryService() throws Exception { + StagingRepository repository = mock(StagingRepository.class); + RepositoryService repositoryService = mock(RepositoryService.class); + when(repositoryService.find(123)).thenReturn(repository); + Artifact pom = new Artifact(repository, "org.apache.sling", ARTIFACT, "1.3.6", null, "pom"); + when(repositoryService.getArtifacts(repository)).thenReturn(Set.of(pom)); + + CredentialsService credentialsService = mock(CredentialsService.class); + when(credentialsService.getAsfCredentials()).thenReturn(new Credentials("johndoe", "secret")); + + osgiContext.registerService(RepositoryService.class, repositoryService); + osgiContext.registerService(CredentialsService.class, credentialsService); + } + + private Command createCommand(ExecutionMode executionMode, String previousVersion) throws IllegalAccessException { + UpdateDistCommand updateDistCommand = spy(new UpdateDistCommand()); + FieldUtils.writeField(updateDistCommand, "repositoryId", 123, true); + FieldUtils.writeField(updateDistCommand, "previousVersion", previousVersion, true); + ReusableCLIOptions reusableCLIOptions = mock(ReusableCLIOptions.class); + FieldUtils.writeField(reusableCLIOptions, "executionMode", executionMode, true); + FieldUtils.writeField(updateDistCommand, "reusableCLIOptions", reusableCLIOptions, true); + osgiContext.registerInjectActivateService(updateDistCommand); + Command result = osgiContext.getService(Command.class); + assertTrue( + "Expected to retrieve the UpdateDistCommand from the mocked OSGi environment.", + result instanceof UpdateDistCommand); + return result; + } +}
