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;
+    }
+}


Reply via email to