You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@sling.apache.org by ro...@apache.org on 2026/07/04 13:04:23 UTC

(sling-org-apache-sling-committer-cli) 01/01: SLING-13253 - release update-dist command using embedded SVNKit

This is an automated email from the ASF dual-hosted git repository.

royteeuwen pushed a commit to branch feature/SLING-13253-update-dist
in repository https://gitbox.apache.org/repos/asf/sling-org-apache-sling-committer-cli.git

commit 69d004e9412051fd9570993ec8595604a8e84114
Author: Roy Teeuwen <ro...@apache.org>
AuthorDate: Sat Jul 4 15:04:17 2026 +0200

    SLING-13253 - release update-dist command using embedded SVNKit
    
    Adds 'release update-dist' to move release artifacts from dist/dev to
    dist/release on dist.apache.org and remove the previous release, using the
    pure-Java SVNKit client over https instead of shelling out to svn/svnmucc.
    
    SVNKit is embedded into the sling-cli bundle via bnd (its OSGi bundles are
    published only to the Eclipse P2 update site, not Maven Central); only the jars
    needed for https/DAV url operations are embedded, and the svn+ssh:// stack is
    marked optional so the bundle resolves in Felix.
    
    Part of splitting PR #28 into per-command changes; builds on the list PR.
---
 bnd.bnd                                            |  28 +-
 pom.xml                                            |  39 +++
 .../sling/cli/impl/release/UpdateDistCommand.java  | 265 +++++++++++++++++
 .../cli/impl/release/UpdateDistCommandTest.java    | 328 +++++++++++++++++++++
 4 files changed, 659 insertions(+), 1 deletion(-)

diff --git a/bnd.bnd b/bnd.bnd
index 78e45e3..658fa39 100644
--- a/bnd.bnd
+++ b/bnd.bnd
@@ -1 +1,27 @@
-Import-Package: !org.fusesource.jansi, *
+# Embed SVNKit and the minimal transitive jars it needs for https/DAV url operations into this
+# bundle (see pom.xml). Everything lands on a single classloader so SVNKit's internal reflection
+# and service lookups resolve without cross-bundle wiring.
+-includeresource: \
+    @svnkit-[0-9.]*.jar, \
+    @sequence-library-[0-9.]*.jar, \
+    @sqljet-[0-9.]*.jar, \
+    @antlr-runtime-[0-9.]*.jar
+
+# The svn+ssh:// / native / server-side packages are intentionally not embedded; SVNKit references
+# them but never loads them on the https url code path, so import them optionally to keep the bundle
+# resolvable in the Felix runtime.
+Import-Package: \
+    !org.fusesource.jansi, \
+    org.apache.sshd.*;resolution:=optional, \
+    com.sun.jna.*;resolution:=optional, \
+    com.trilead.*;resolution:=optional, \
+    com.jcraft.*;resolution:=optional, \
+    net.i2p.crypto.*;resolution:=optional, \
+    net.jpountz.*;resolution:=optional, \
+    javax.servlet.*;resolution:=optional, \
+    org.tigris.subversion.javahl.*;resolution:=optional, \
+    org.antlr.stringtemplate.*;resolution:=optional, \
+    jcifs.*;resolution:=optional, \
+    de.rbri.*;resolution:=optional, \
+    javax.security.sasl;resolution:=optional, \
+    *
diff --git a/pom.xml b/pom.xml
index 1a4c18c..9cba820 100644
--- a/pom.xml
+++ b/pom.xml
@@ -131,6 +131,45 @@
             <version>72.1</version>
             <scope>provided</scope>
         </dependency>
+        <!--
+            SVNKit is the only pure-Java Subversion client; its OSGi bundles are published only to the
+            Eclipse P2 update site, not to Maven Central. We therefore embed it (and the minimal set of
+            transitive jars needed for https/DAV url operations) into the sling-cli bundle via bnd. The
+            svn+ssh:// stack (sshd, jna, trilead, jsch-agentproxy, eddsa, lz4) is excluded and its
+            packages are marked optional in bnd.bnd.
+        -->
+        <dependency>
+            <groupId>org.tmatesoft.svnkit</groupId>
+            <artifactId>svnkit</artifactId>
+            <version>1.10.11</version>
+            <scope>provided</scope>
+            <exclusions>
+                <exclusion>
+                    <groupId>org.apache.sshd</groupId>
+                    <artifactId>*</artifactId>
+                </exclusion>
+                <exclusion>
+                    <groupId>net.i2p.crypto</groupId>
+                    <artifactId>*</artifactId>
+                </exclusion>
+                <exclusion>
+                    <groupId>net.java.dev.jna</groupId>
+                    <artifactId>*</artifactId>
+                </exclusion>
+                <exclusion>
+                    <groupId>com.trilead</groupId>
+                    <artifactId>*</artifactId>
+                </exclusion>
+                <exclusion>
+                    <groupId>com.jcraft</groupId>
+                    <artifactId>*</artifactId>
+                </exclusion>
+                <exclusion>
+                    <groupId>org.lz4</groupId>
+                    <artifactId>*</artifactId>
+                </exclusion>
+            </exclusions>
+        </dependency>
         <dependency>
             <groupId>junit</groupId>
             <artifactId>junit</artifactId>
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..4ff653d
--- /dev/null
+++ b/src/main/java/org/apache/sling/cli/impl/release/UpdateDistCommand.java
@@ -0,0 +1,265 @@
+/*
+ * 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.util.ArrayList;
+import java.util.Collection;
+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 org.tmatesoft.svn.core.SVNDirEntry;
+import org.tmatesoft.svn.core.SVNException;
+import org.tmatesoft.svn.core.SVNURL;
+import org.tmatesoft.svn.core.auth.BasicAuthenticationManager;
+import org.tmatesoft.svn.core.internal.io.dav.DAVRepositoryFactory;
+import org.tmatesoft.svn.core.io.SVNRepository;
+import org.tmatesoft.svn.core.io.SVNRepositoryFactory;
+import org.tmatesoft.svn.core.wc.SVNRevision;
+import org.tmatesoft.svn.core.wc2.SvnCopySource;
+import org.tmatesoft.svn.core.wc2.SvnOperationFactory;
+import org.tmatesoft.svn.core.wc2.SvnRemoteCopy;
+import org.tmatesoft.svn.core.wc2.SvnRemoteDelete;
+import org.tmatesoft.svn.core.wc2.SvnTarget;
+import picocli.CommandLine;
+
+/**
+ * Moves release artifacts from dist/dev to dist/release on dist.apache.org and removes the
+ * previous release, using SVNKit's pure-Java Subversion client over https.
+ * 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
+        })
+@CommandLine.Command(
+        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/";
+
+    static {
+        // register the http(s):// DAV repository factory used by all SVNKit operations below
+        DAVRepositoryFactory.setup();
+    }
+
+    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 = listDistFiles(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)) {
+                        commitDistUpdate(
+                                artifactId, newVersion, newFiles, oldFiles, credentialsService.getAsfCredentials());
+                    } else {
+                        LOGGER.info("Aborted.");
+                    }
+                    break;
+                case AUTO:
+                    commitDistUpdate(
+                            artifactId, newVersion, newFiles, oldFiles, credentialsService.getAsfCredentials());
+                    break;
+            }
+        } catch (IOException e) {
+            LOGGER.warn("Failed executing command", e);
+            return CommandLine.ExitCode.SOFTWARE;
+        }
+        return CommandLine.ExitCode.OK;
+    }
+
+    /**
+     * Moves {@code newFiles} from {@code dist/dev} to {@code dist/release} and removes {@code oldFiles}
+     * from {@code dist/release}, using SVNKit over https. The move (copy + delete of the sources) is a
+     * single atomic commit; the removal of superseded files is a second commit. Ordering is deliberate:
+     * the new release is fully published before the previous one is removed, so an interrupted run never
+     * leaves {@code dist/release} without a released version.
+     */
+    static void commitDistUpdate(
+            String artifactId, String newVersion, List<String> newFiles, List<String> oldFiles, Credentials credentials)
+            throws IOException {
+        Logger logger = LoggerFactory.getLogger(UpdateDistCommand.class);
+        SvnOperationFactory svnOperationFactory = new SvnOperationFactory();
+        svnOperationFactory.setAuthenticationManager(
+                new BasicAuthenticationManager(credentials.getUsername(), credentials.getPassword()));
+        try {
+            SvnRemoteCopy move = svnOperationFactory.createRemoteCopy();
+            move.setMove(true);
+            move.setFailWhenDstExists(false);
+            move.setCommitMessage("Release " + artifactId + " " + newVersion);
+            move.setSingleTarget(SvnTarget.fromURL(SVNURL.parseURIEncoded(stripTrailingSlash(DIST_RELEASE_URL))));
+            for (String file : newFiles) {
+                move.addCopySource(SvnCopySource.create(
+                        SvnTarget.fromURL(SVNURL.parseURIEncoded(DIST_DEV_URL + file)), SVNRevision.HEAD));
+            }
+            logger.info("Moving {} file(s) from dist/dev to dist/release...", newFiles.size());
+            move.run();
+
+            if (!oldFiles.isEmpty()) {
+                SvnRemoteDelete delete = svnOperationFactory.createRemoteDelete();
+                delete.setCommitMessage("Remove superseded release of " + artifactId);
+                for (String file : oldFiles) {
+                    delete.addTarget(SvnTarget.fromURL(SVNURL.parseURIEncoded(DIST_RELEASE_URL + file)));
+                }
+                logger.info("Removing {} superseded file(s) from dist/release...", oldFiles.size());
+                delete.run();
+            }
+            logger.info("Done. dist.apache.org has been updated.");
+        } catch (SVNException e) {
+            throw new IOException("Failed to update dist.apache.org", e);
+        } finally {
+            svnOperationFactory.dispose();
+        }
+    }
+
+    /**
+     * 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 listDistFiles(DIST_RELEASE_URL, artifactId + "-" + explicitPreviousVersion);
+        }
+        return listDistFiles(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))
+                .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 must be the whole name, or be followed by an extension dot or a classifier dash;
+        // this avoids matching version 1.0.14 against the longer 1.0.140
+        if (fileName.length() == prefix.length()) {
+            return true;
+        }
+        char next = fileName.charAt(prefix.length());
+        return next == '.' || next == '-';
+    }
+
+    static List<String> listDistFiles(String baseUrl, String prefix) throws IOException {
+        List<String> files = new ArrayList<>();
+        try {
+            SVNRepository repository = SVNRepositoryFactory.create(SVNURL.parseURIEncoded(baseUrl));
+            long revision = repository.getLatestRevision();
+            Collection<SVNDirEntry> entries = new ArrayList<>();
+            repository.getDir("", revision, null, entries);
+            entries.stream()
+                    .map(SVNDirEntry::getName)
+                    .filter(name -> name.startsWith(prefix))
+                    .forEach(files::add);
+        } catch (SVNException e) {
+            throw new IOException("Failed to list " + baseUrl, e);
+        }
+        return files;
+    }
+
+    private static String stripTrailingSlash(String url) {
+        return url.endsWith("/") ? url.substring(0, url.length() - 1) : url;
+    }
+}
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..2caf612
--- /dev/null
+++ b/src/test/java/org/apache/sling/cli/impl/release/UpdateDistCommandTest.java
@@ -0,0 +1,328 @@
+/*
+ * 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.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.InputOption;
+import org.apache.sling.cli.impl.UserInput;
+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.listDistFiles(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.listDistFiles(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.listDistFiles(
+                            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.listDistFiles(eq(UpdateDistCommand.DIST_RELEASE_URL), eq(ARTIFACT + "-")),
+                    never());
+        }
+    }
+
+    @Test
+    public void testAutoDeduceKeepsNewVersionWithClassifierAndExtension() throws Exception {
+        // files for the version being published (with both an extension '.' and a classifier '-' right
+        // after the version) must be kept, exercising belongsToVersion's trailing-character check
+        List<String> releaseDir = List.of(
+                ARTIFACT + "-1.3.6", // exact match: filename equals the version prefix with no extension
+                ARTIFACT + "-1.3.6.pom",
+                ARTIFACT + "-1.3.6-source-release.zip",
+                ARTIFACT + "-1.3.4.pom");
+        try (MockedStatic<UpdateDistCommand> dist = mockStatic(UpdateDistCommand.class, CALLS_REAL_METHODS)) {
+            dist.when(() -> UpdateDistCommand.listDistFiles(eq(UpdateDistCommand.DIST_RELEASE_URL), anyString()))
+                    .thenReturn(releaseDir);
+
+            List<String> old = UpdateDistCommand.listPreviousReleaseFiles(ARTIFACT, "1.3.6", null);
+
+            assertEquals(List.of(ARTIFACT + "-1.3.4.pom"), old);
+            assertFalse(old.contains(ARTIFACT + "-1.3.6"));
+            assertFalse(old.contains(ARTIFACT + "-1.3.6.pom"));
+            assertFalse(old.contains(ARTIFACT + "-1.3.6-source-release.zip"));
+        }
+    }
+
+    // ---- full command flow ----
+
+    @Test
+    public void testDryRunDescribesMoveAndRemoveWithoutCommitting() throws Exception {
+        prepareRepositoryService();
+        try (MockedStatic<UpdateDistCommand> dist = mockStatic(UpdateDistCommand.class, CALLS_REAL_METHODS)) {
+            dist.when(() -> UpdateDistCommand.listDistFiles(eq(UpdateDistCommand.DIST_DEV_URL), anyString()))
+                    .thenReturn(List.of(ARTIFACT + "-1.3.6-source-release.zip"));
+            dist.when(() -> UpdateDistCommand.listDistFiles(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.commitDistUpdate(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.listDistFiles(eq(UpdateDistCommand.DIST_DEV_URL), anyString()))
+                    .thenReturn(List.of(ARTIFACT + "-1.3.6-source-release.zip"));
+            dist.when(() -> UpdateDistCommand.listDistFiles(eq(UpdateDistCommand.DIST_RELEASE_URL), anyString()))
+                    .thenReturn(List.of(ARTIFACT + "-1.3.4.pom"));
+            // do not actually shell out to svnmucc
+            dist.when(() -> UpdateDistCommand.commitDistUpdate(any(), any(), any(), any(), any()))
+                    .thenAnswer(invocation -> null);
+
+            Command command = createCommand(ExecutionMode.AUTO, null);
+            assertEquals(CommandLine.ExitCode.OK, (int) command.call());
+
+            dist.verify(() -> UpdateDistCommand.commitDistUpdate(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.listDistFiles(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.listDistFiles(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.commitDistUpdate(any(), any(), any(), any(), any()), never());
+        }
+    }
+
+    @Test
+    public void testInteractiveYesCommitsViaSvnMucc() throws Exception {
+        prepareRepositoryService();
+        try (MockedStatic<UpdateDistCommand> dist = mockStatic(UpdateDistCommand.class, CALLS_REAL_METHODS);
+                MockedStatic<UserInput> userInput = mockStatic(UserInput.class)) {
+            dist.when(() -> UpdateDistCommand.listDistFiles(eq(UpdateDistCommand.DIST_DEV_URL), anyString()))
+                    .thenReturn(List.of(ARTIFACT + "-1.3.6-source-release.zip"));
+            dist.when(() -> UpdateDistCommand.listDistFiles(eq(UpdateDistCommand.DIST_RELEASE_URL), anyString()))
+                    .thenReturn(List.of(ARTIFACT + "-1.3.4.pom"));
+            dist.when(() -> UpdateDistCommand.commitDistUpdate(any(), any(), any(), any(), any()))
+                    .thenAnswer(invocation -> null);
+            userInput
+                    .when(() -> UserInput.yesNo(anyString(), eq(InputOption.YES)))
+                    .thenReturn(InputOption.YES);
+
+            Command command = createCommand(ExecutionMode.INTERACTIVE, null);
+            assertEquals(CommandLine.ExitCode.OK, (int) command.call());
+
+            dist.verify(() -> UpdateDistCommand.commitDistUpdate(eq(ARTIFACT), eq("1.3.6"), any(), any(), any()));
+        }
+    }
+
+    @Test
+    public void testInteractiveNoAborts() throws Exception {
+        prepareRepositoryService();
+        try (MockedStatic<UpdateDistCommand> dist = mockStatic(UpdateDistCommand.class, CALLS_REAL_METHODS);
+                MockedStatic<UserInput> userInput = mockStatic(UserInput.class)) {
+            dist.when(() -> UpdateDistCommand.listDistFiles(eq(UpdateDistCommand.DIST_DEV_URL), anyString()))
+                    .thenReturn(List.of(ARTIFACT + "-1.3.6-source-release.zip"));
+            dist.when(() -> UpdateDistCommand.listDistFiles(eq(UpdateDistCommand.DIST_RELEASE_URL), anyString()))
+                    .thenReturn(List.of(ARTIFACT + "-1.3.4.pom"));
+            userInput
+                    .when(() -> UserInput.yesNo(anyString(), eq(InputOption.YES)))
+                    .thenReturn(InputOption.NO);
+
+            Command command = createCommand(ExecutionMode.INTERACTIVE, null);
+            assertEquals(CommandLine.ExitCode.OK, (int) command.call());
+
+            assertTrue(logCapture.containsMessage("Aborted."));
+            dist.verify(() -> UpdateDistCommand.commitDistUpdate(any(), any(), any(), any(), any()), never());
+        }
+    }
+
+    @Test
+    public void testExplicitPreviousVersionFullFlow() throws Exception {
+        prepareRepositoryService();
+        try (MockedStatic<UpdateDistCommand> dist = mockStatic(UpdateDistCommand.class, CALLS_REAL_METHODS)) {
+            dist.when(() -> UpdateDistCommand.listDistFiles(eq(UpdateDistCommand.DIST_DEV_URL), anyString()))
+                    .thenReturn(List.of(ARTIFACT + "-1.3.6-source-release.zip"));
+            dist.when(() -> UpdateDistCommand.listDistFiles(
+                            eq(UpdateDistCommand.DIST_RELEASE_URL), eq(ARTIFACT + "-1.3.4")))
+                    .thenReturn(List.of(ARTIFACT + "-1.3.4.pom"));
+            dist.when(() -> UpdateDistCommand.commitDistUpdate(any(), any(), any(), any(), any()))
+                    .thenAnswer(invocation -> null);
+
+            Command command = createCommand(ExecutionMode.AUTO, "1.3.4");
+            assertEquals(CommandLine.ExitCode.OK, (int) command.call());
+
+            dist.verify(() -> UpdateDistCommand.commitDistUpdate(eq(ARTIFACT), eq("1.3.6"), any(), any(), any()));
+        }
+    }
+
+    @Test
+    public void testIOExceptionReturnsSoftware() throws Exception {
+        prepareRepositoryService();
+        try (MockedStatic<UpdateDistCommand> dist = mockStatic(UpdateDistCommand.class, CALLS_REAL_METHODS)) {
+            dist.when(() -> UpdateDistCommand.listDistFiles(any(), anyString())).thenThrow(new IOException("svn boom"));
+
+            Command command = createCommand(ExecutionMode.AUTO, null);
+            assertEquals(CommandLine.ExitCode.SOFTWARE, (int) command.call());
+            assertTrue(logCapture.containsMessage("Failed executing command"));
+        }
+    }
+
+    @Test
+    public void testNoPomArtifactThrows() throws Exception {
+        StagingRepository repository = mock(StagingRepository.class);
+        RepositoryService repositoryService = mock(RepositoryService.class);
+        when(repositoryService.find(123)).thenReturn(repository);
+        // a staging repository without a POM artifact triggers the orElseThrow guard
+        Artifact jar = new Artifact(repository, "org.apache.sling", ARTIFACT, "1.3.6", null, "jar");
+        when(repositoryService.getArtifacts(repository)).thenReturn(Set.of(jar));
+        CredentialsService credentialsService = mock(CredentialsService.class);
+        osgiContext.registerService(RepositoryService.class, repositoryService);
+        osgiContext.registerService(CredentialsService.class, credentialsService);
+
+        Command command = createCommand(ExecutionMode.AUTO, null);
+        try {
+            command.call();
+            org.junit.Assert.fail("Expected an IllegalStateException when no POM artifact is present.");
+        } catch (IllegalStateException e) {
+            assertTrue(e.getMessage().contains("No POM artifact"));
+        }
+    }
+
+    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;
+    }
+}