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

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

commit 1344064173bf649e4fdb5ef2b0e1cde2b12a3d27
Author: Roy Teeuwen <[email protected]>
AuthorDate: Sat Jul 4 15:05:36 2026 +0200

    SLING-13253 - release finalize command and act-by-name support
    
    Adds 'release finalize', which runs all post-vote finalization steps in one
    command (promote to Maven Central, update dist.apache.org, create/release 
the
    JIRA version, and report to the Apache Reporter). Also lets the post-vote
    commands act on a release by name in addition to a staging repository id, 
and
    adds tests for the JIRA-version and verify commands. Updates the README with
    the end-to-end release workflow.
    
    Part of splitting PR #28 into per-command changes; builds on the 
update-dist PR.
---
 README.md                                          | 204 +++++++++++++--
 .../cli/impl/release/CreateJiraVersionCommand.java |  29 ++-
 .../sling/cli/impl/release/FinalizeCommand.java    | 274 +++++++++++++++++++++
 .../impl/release/ReleaseJiraVersionCommand.java    |  29 ++-
 .../cli/impl/release/UpdateLocalSiteCommand.java   |  28 ++-
 .../cli/impl/release/UpdateReporterCommand.java    |  28 ++-
 .../impl/release/CreateJiraVersionCommandTest.java | 217 ++++++++++++++++
 .../cli/impl/release/FinalizeCommandTest.java      | 235 ++++++++++++++++++
 .../release/ReleaseJiraVersionCommandTest.java     | 152 ++++++++++++
 .../impl/release/UpdateLocalSiteCommandTest.java   | 154 ++++++++++++
 .../impl/release/UpdateReporterCommandTest.java    |  51 ++++
 .../impl/release/VerifyReleasesCommandTest.java    | 127 ++++++++++
 12 files changed, 1486 insertions(+), 42 deletions(-)

diff --git a/README.md b/README.md
index 3334d40..8f17c15 100644
--- a/README.md
+++ b/README.md
@@ -9,17 +9,108 @@ This module is part of the [Apache 
Sling](https://sling.apache.org) project.
 This module provides a command-line tool which automates various Sling 
development tasks. The tool is packaged
 as a docker image.
 
-## Configuration
-
-To make various credentials and configurations available to the docker image 
it is recommended to use a docker env file.
-A sample file is stored at `docker-env.sample`. Copy this file to `docker-env` 
and fill in your own information.
+## Prerequisites
+
+Before releasing you need the following in place. These are split between the 
**Maven side** (used by
+`mvn release:prepare`/`release:perform` in the project being released) and the 
**CLI side** (used by
+this Docker tool).
+
+### 1. ASF credentials for the CLI (`docker-env`)
+
+To make credentials available to the docker image, use a docker env file. A 
sample file is stored at
+`docker-env.sample`; copy it to `docker-env` and fill in your own information. 
At minimum it must
+provide your ASF credentials (these are read by the CLI for Nexus, JIRA, 
Whimsy and the mailing
+lists):
+
+    ASF_USERNAME=your-apache-id
+    ASF_PASSWORD=your-apache-password
+
+### 2. GPG signing key
+
+* Generate a code-signing key and publish it: `gpg --send-keys <KEY_ID>` to 
`keys.openpgp.org`, and
+  add its public block to the Sling `KEYS` file at
+  <https://dist.apache.org/repos/dist/release/sling/KEYS> (PMC members can 
commit there directly).
+* Note the key id (e.g. `0A1B2C3D4E5F6789`) — it goes into `settings.xml` 
below.
+
+### 3. Maven `~/.m2/settings.xml`
+
+Recent Apache/Sling parent POMs use **maven-gpg-plugin 3.x**, which changed 
how the GPG passphrase is
+supplied. The old approach of a `<gpg.passphrase>` property in the 
`apache-release` profile is **no
+longer used** — the plugin now reads the passphrase from a **server** whose id 
is given by
+`gpg.passphraseServerId` (default: `gpg.passphrase`) and decrypts it via 
`settings-security.xml`.
+
+```xml
+<settings>
+  <servers>
+    <!-- ASF Nexus credentials (encrypted, see settings-security.xml) -->
+    <server>
+      <id>apache.snapshots.https</id>
+      <username>your-apache-id</username>
+      <password>{ENCRYPTED}</password>
+    </server>
+    <server>
+      <id>apache.releases.https</id>
+      <username>your-apache-id</username>
+      <password>{ENCRYPTED}</password>
+    </server>
+
+    <!-- maven-gpg-plugin 3.x reads the passphrase from THIS server id 
(gpg.passphraseServerId
+         defaults to "gpg.passphrase") and decrypts it via 
settings-security.xml. -->
+    <server>
+      <id>gpg.passphrase</id>
+      <passphrase>{ENCRYPTED}</passphrase>
+    </server>
+  </servers>
+  <profiles>
+    <profile>
+      <id>apache-release</id>
+      <properties>
+        <apache.availid>your-apache-id</apache.availid>
+        <!-- required so gpg does not try to open an interactive pinentry 
dialog -->
+        <gpg.pinentryMode>loopback</gpg.pinentryMode>
+        <!-- the signing key id from step 2 -->
+        <gpg.keyname>YOUR_KEY_ID</gpg.keyname>
+        <!-- SMTP host used by the parent POM's announcement tooling -->
+        <smtp.host>smtp.gmail.com</smtp.host>
+      </properties>
+    </profile>
+  </profiles>
+</settings>
+```
+
+Encrypt the passwords/passphrase with `mvn --encrypt-password` (server 
passwords) and store the master
+password in `~/.m2/settings-security.xml`:
+
+```xml
+<settingsSecurity>
+  <master>{ENCRYPTED_MASTER_PASSWORD}</master>
+</settingsSecurity>
+```
+
+## Building
+
+The Docker image (`apache/sling-cli:latest`) is produced by the `docker:build` 
goal of the
+[fabric8 docker-maven-plugin](https://dmp.fabric8.io/). The image bundles the 
project jar via the
+generated `*-app.slingfeature` descriptor; the `slingfeature-maven-plugin` 
resolves the project's
+own bundle from the reactor, so the image always contains the jar built in the 
same invocation.
+
+A single command builds (and tests) the project and the image:
+
+    mvn clean package docker:build
+
+No prior `mvn install` is needed. The `docker:build` execution is also bound 
to the `package`
+phase, so the CI build (`mvn package`) builds the image too.
+
+To confirm the image contains the expected commands:
+
+    docker run --env-file=./docker-env apache/sling-cli release help
 
 ## Launching
 
-The image is built using `mvn package`. Afterwards it may be run with
+After building, run the image with:
 
     docker run --env-file=./docker-env apache/sling-cli
-    
+
 This invocation produces a list of available commands.
 
 ## Commands
@@ -39,21 +130,102 @@ To select a non-default execution mode provide the mode 
as an argument to the co
 Note that for running commands in the `INTERACTIVE` mode you need to run the 
Docker container in interactive mode with a pseudo-tty 
 attached (e.g. `docker run -it ...`).
 
-Listing active releases
+### Release workflow
+
+The full release lifecycle has **manual Maven steps** (run in the project 
being released) and **CLI
+steps** (run via this Docker tool). The CLI commands take the numeric staging 
repository id via
+`-r`/`--repository` (e.g. `3103` for `orgapachesling-3103`). Append `-x AUTO` 
to actually perform an
+action (the default mode is `DRY_RUN`).
+
+#### Manual steps in the project being released (Maven)
+
+These are **not** part of this tool — run them in the module you are 
releasing, with the prerequisites
+from the section above configured:
+
+1. Make sure the parent POM is up to date and the build is green (`mvn clean 
verify`).
+2. Dry-run the release and check that only `<version>`/`<scm>` change:
+
+       mvn release:prepare -DdryRun=true
+       mvn release:clean
+
+3. Deploy a snapshot and confirm `META-INF/LICENSE` and `META-INF/NOTICE` are 
in the jar:
+
+       mvn deploy
+
+4. Prepare and perform the release (creates the tag, bumps to the next 
SNAPSHOT, signs and stages the
+   artifacts to Nexus):
+
+       mvn release:prepare
+       mvn release:perform
+
+   Note the staging repository id from the `release:perform` output (a line 
like
+   `…/orgapachesling-1087`). If it scrolls past, `release list` (below) shows 
open repos too.
+
+#### CLI steps (this tool)
+
+After `release:perform` has staged the artifacts, drive the rest with the CLI:
+
+0. **Find the staging repository id** if you did not capture it. `release 
list` shows every staging
+   repo with its `[open]`/`[closed]` state and description; a freshly staged 
one is `[open]`:
+
+       docker run --env-file=./docker-env apache/sling-cli release list
+
+1. **Close** the staging repository. The description is derived automatically 
from the staged POM's
+   `<name>` + `<version>` (e.g. _Apache Sling Feature Model Launcher 1.3.6_) 
by browsing the
+   repository content, so it works even though an open repository is not yet 
in the Lucene index:
+
+       docker run --env-file=./docker-env apache/sling-cli release 
close-staging --repository=$STAGING_REPOSITORY_ID --execution-mode=AUTO
+
+2. **Verify** the artifacts' signatures, hashes and CI status:
+
+       docker run --env-file=./docker-env apache/sling-cli release verify 
--repository=$STAGING_REPOSITORY_ID
+
+3. **Generate the vote email**:
+
+       docker run --env-file=./docker-env apache/sling-cli release 
prepare-email --repository=$STAGING_REPOSITORY_ID --execution-mode=AUTO
+
+4. After the 72h vote, **tally the votes** and generate the result email. PMC 
membership is detected
+   automatically from your ASF id: if you are a PMC member the email says you 
will copy the release to
+   the dist directory yourself; otherwise it asks a PMC member to perform the 
dist upload:
+
+       docker run --env-file=./docker-env apache/sling-cli release tally-votes 
--repository=$STAGING_REPOSITORY_ID --execution-mode=AUTO
+
+5. **Finalize** the release (post successful vote). This runs, in order: 
promote to Maven Central,
+   create the next Jira version, release the current Jira version, and update 
the Apache Reporter:
+
+       docker run --env-file=./docker-env apache/sling-cli release finalize 
--repository=$STAGING_REPOSITORY_ID --execution-mode=AUTO
+
+   When the current user is detected as a PMC member, `finalize` additionally 
publishes to
+   `dist.apache.org` (requires `subversion`, which is bundled in the image). 
The previous version to
+   remove from `dist/release` is deduced automatically from the directory 
contents, so no extra flag
+   is needed:
+
+       docker run --env-file=./docker-env apache/sling-cli release finalize 
--repository=$STAGING_REPOSITORY_ID --execution-mode=AUTO
 
-    docker run --env-file=./docker-env apache/sling-cli release list
+   PMC membership is determined from your ASF id (via Whimsy). A non-PMC 
committer's `finalize` skips
+   the dist upload and the `tally-votes` result email asks a PMC member to 
perform it.
 
-Generating a release vote email
+If the vote does not pass, **drop** the staging repository:
 
-    docker run --env-file=./docker-env apache/sling-cli release prepare-email 
--repository=$STAGING_REPOSITORY_ID
-    
-Generating a release vote result email
+    docker run --env-file=./docker-env apache/sling-cli release drop 
--repository=$STAGING_REPOSITORY_ID --execution-mode=AUTO
 
-    docker run --env-file=./docker-env apache/sling-cli release tally-votes 
--repository=$STAGING_REPOSITORY_ID
-    
-Generating the website update (only diff for now)
+### Command reference
 
-       docker run --env-file=docker-env apache/sling-cli release 
update-local-site --repository=$STAGING_REPOSITORY_ID
+| Command | Description |
+|---------|-------------|
+| `release list` | List closed staging repositories |
+| `release close-staging -r <id>` | Close an open staging repo, setting the 
description from the staged POM |
+| `release verify -r <id>` | Download and verify artifact signatures, hashes 
and CI status |
+| `release prepare-email -r <id>` | Generate (and send) the `[VOTE]` email |
+| `release tally-votes -r <id>` | Count votes and generate the `[RESULT]` 
email (PMC membership auto-detected; non-PMC email asks a PMC member to do the 
dist upload) |
+| `release promote -r <id>` | Promote a closed staging repo to Maven Central |
+| `release update-dist -r <id>` | Move artifacts to `dist.apache.org` (PMC 
only); previous version auto-deduced, override with `--previous-version <v>` |
+| `release finalize -r <id>` | Promote + Jira + Reporter in one step; also 
updates `dist.apache.org` when you are a PMC member |
+| `release drop -r <id>` | Drop a staging repository (failed vote / cleanup) |
+| `release create-new-jira-version -r <id>` | Create the next Jira version and 
move unresolved issues |
+| `release release-jira-version -r <id>` | Mark the Jira version as released 
and close fixed issues |
+| `release update-reporter -r <id>` | Register the release with the Apache 
Reporter System |
+| `release update-local-site -r <id>` | Generate the website update (diff only 
for now) |
 
 ## Assumptions
 
diff --git 
a/src/main/java/org/apache/sling/cli/impl/release/CreateJiraVersionCommand.java 
b/src/main/java/org/apache/sling/cli/impl/release/CreateJiraVersionCommand.java
index 226e01a..41170dc 100644
--- 
a/src/main/java/org/apache/sling/cli/impl/release/CreateJiraVersionCommand.java
+++ 
b/src/main/java/org/apache/sling/cli/impl/release/CreateJiraVersionCommand.java
@@ -30,7 +30,6 @@ 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.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;
@@ -56,8 +55,7 @@ public class CreateJiraVersionCommand implements Command {
 
     @CommandLine.Option(
             names = {"-r", "--repository"},
-            description = "Nexus repository id",
-            required = true)
+            description = "Nexus staging repository id to derive the 
release(s) from")
     private Integer repositoryId;
 
     @Reference
@@ -67,9 +65,10 @@ public class CreateJiraVersionCommand implements Command {
     private VersionClient versionClient;
 
     @CommandLine.Option(
-            names = {"--version-name"},
-            description = "Jira version name to use",
-            required = false)
+            names = {"--release", "--version-name"},
+            description = "Release name(s) to act on, e.g. \"Apache Sling Foo 
1.2.0\" (comma-separated for multiple)."
+                    + " Use instead of --repository when the staging 
repository no longer exists,"
+                    + " e.g. after the release has been promoted.")
     private String jiraVersionName;
 
     @CommandLine.Mixin
@@ -80,7 +79,12 @@ public class CreateJiraVersionCommand implements Command {
     @Override
     public Integer call() {
         try {
-            for (Release release : releases()) {
+            Collection<Release> releases = releases();
+            if (releases.isEmpty()) {
+                logger.error("Provide either --repository or --release.");
+                return CommandLine.ExitCode.USAGE;
+            }
+            for (Release release : releases) {
                 Version version = versionClient.find(release);
                 logger.info("Found {}.", version);
                 Version successorVersion = 
versionClient.findSuccessorVersion(release);
@@ -145,9 +149,12 @@ public class CreateJiraVersionCommand implements Command {
     }
 
     private Collection<Release> releases() throws IOException {
-        if (jiraVersionName != null) return 
Release.fromString(jiraVersionName);
-
-        StagingRepository repo = repositoryService.find(repositoryId);
-        return repositoryService.getReleases(repo);
+        if (jiraVersionName != null && !jiraVersionName.isBlank()) {
+            return Release.fromString(jiraVersionName);
+        }
+        if (repositoryId == null) {
+            return List.of();
+        }
+        return 
repositoryService.getReleases(repositoryService.find(repositoryId));
     }
 }
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..a728638
--- /dev/null
+++ b/src/main/java/org/apache/sling/cli/impl/release/FinalizeCommand.java
@@ -0,0 +1,274 @@
+/*
+ * 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.time.Instant;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+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 (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            LOGGER.warn("Failed executing command", e);
+            return CommandLine.ExitCode.SOFTWARE;
+        } 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 {
+        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.listDistFiles(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.commitDistUpdate(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()) {
+            Instant now = Instant.now();
+            String xdate = DateTimeFormatter.ISO_LOCAL_DATE
+                    .withZone(ZoneId.systemDefault())
+                    .format(now);
+            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.getEpochSecond())));
+                params.add(new BasicNameValuePair("committee", "sling"));
+                params.add(new BasicNameValuePair("version", 
release.getFullName()));
+                params.add(new BasicNameValuePair("xdate", xdate));
+                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/ReleaseJiraVersionCommand.java
 
b/src/main/java/org/apache/sling/cli/impl/release/ReleaseJiraVersionCommand.java
index 5aa9a1c..5eaa893 100644
--- 
a/src/main/java/org/apache/sling/cli/impl/release/ReleaseJiraVersionCommand.java
+++ 
b/src/main/java/org/apache/sling/cli/impl/release/ReleaseJiraVersionCommand.java
@@ -18,6 +18,7 @@
  */
 package org.apache.sling.cli.impl.release;
 
+import java.io.IOException;
 import java.util.List;
 import java.util.Set;
 
@@ -28,7 +29,6 @@ import org.apache.sling.cli.impl.UserInput;
 import org.apache.sling.cli.impl.jira.Issue;
 import org.apache.sling.cli.impl.jira.VersionClient;
 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;
@@ -58,10 +58,16 @@ public class ReleaseJiraVersionCommand implements Command {
 
     @CommandLine.Option(
             names = {"-r", "--repository"},
-            description = "Nexus repository id",
-            required = true)
+            description = "Nexus staging repository id to derive the 
release(s) from")
     private Integer repositoryId;
 
+    @CommandLine.Option(
+            names = {"--release"},
+            description = "Release name(s) to act on, e.g. \"Apache Sling Foo 
1.2.0\" (comma-separated for multiple)."
+                    + " Use instead of --repository when the staging 
repository no longer exists,"
+                    + " e.g. after the release has been promoted.")
+    private String releaseName;
+
     @Reference
     private RepositoryService repositoryService;
 
@@ -71,11 +77,24 @@ public class ReleaseJiraVersionCommand implements Command {
     @CommandLine.Mixin
     private ReusableCLIOptions reusableCLIOptions;
 
+    private Set<Release> resolveReleases() throws IOException {
+        if (releaseName != null && !releaseName.isBlank()) {
+            return Set.copyOf(Release.fromString(releaseName));
+        }
+        if (repositoryId == null) {
+            return Set.of();
+        }
+        return 
repositoryService.getReleases(repositoryService.find(repositoryId));
+    }
+
     @Override
     public Integer call() {
         try {
-            StagingRepository repo = repositoryService.find(repositoryId);
-            Set<Release> releases = repositoryService.getReleases(repo);
+            Set<Release> releases = resolveReleases();
+            if (releases.isEmpty()) {
+                LOGGER.error("Provide either --repository or --release.");
+                return CommandLine.ExitCode.USAGE;
+            }
             ExecutionMode executionMode = reusableCLIOptions.executionMode;
             LOGGER.info(
                     "The following Jira versions {} be released:{}",
diff --git 
a/src/main/java/org/apache/sling/cli/impl/release/UpdateLocalSiteCommand.java 
b/src/main/java/org/apache/sling/cli/impl/release/UpdateLocalSiteCommand.java
index b9a3e6e..e92de4c 100644
--- 
a/src/main/java/org/apache/sling/cli/impl/release/UpdateLocalSiteCommand.java
+++ 
b/src/main/java/org/apache/sling/cli/impl/release/UpdateLocalSiteCommand.java
@@ -28,7 +28,6 @@ import java.util.Set;
 import org.apache.sling.cli.impl.Command;
 import org.apache.sling.cli.impl.jbake.JBakeContentUpdater;
 import org.apache.sling.cli.impl.nexus.RepositoryService;
-import org.apache.sling.cli.impl.nexus.StagingRepository;
 import org.eclipse.jgit.api.Git;
 import org.eclipse.jgit.api.ResetCommand.ResetType;
 import org.eclipse.jgit.api.errors.GitAPIException;
@@ -63,18 +62,37 @@ public class UpdateLocalSiteCommand implements Command {
 
     @CommandLine.Option(
             names = {"-r", "--repository"},
-            description = "Nexus repository id",
-            required = true)
+            description = "Nexus staging repository id to derive the 
release(s) from")
     private Integer repositoryId;
 
+    @CommandLine.Option(
+            names = {"--release"},
+            description = "Release name(s) to act on, e.g. \"Apache Sling Foo 
1.2.0\" (comma-separated for multiple)."
+                    + " Use instead of --repository when the staging 
repository no longer exists,"
+                    + " e.g. after the release has been promoted.")
+    private String releaseName;
+
+    private Set<Release> resolveReleases() throws IOException {
+        if (releaseName != null && !releaseName.isBlank()) {
+            return Set.copyOf(Release.fromString(releaseName));
+        }
+        if (repositoryId == null) {
+            return Set.of();
+        }
+        return 
repositoryService.getReleases(repositoryService.find(repositoryId));
+    }
+
     @Override
     public Integer call() {
         try {
             ensureRepo();
             try (Git git = Git.open(new File(GIT_CHECKOUT))) {
 
-                StagingRepository repository = 
repositoryService.find(repositoryId);
-                Set<Release> releases = 
repositoryService.getReleases(repository);
+                Set<Release> releases = resolveReleases();
+                if (releases.isEmpty()) {
+                    logger.error("Provide either --repository or --release.");
+                    return CommandLine.ExitCode.USAGE;
+                }
 
                 JBakeContentUpdater updater = new JBakeContentUpdater();
 
diff --git 
a/src/main/java/org/apache/sling/cli/impl/release/UpdateReporterCommand.java 
b/src/main/java/org/apache/sling/cli/impl/release/UpdateReporterCommand.java
index 983618c..66e446f 100644
--- a/src/main/java/org/apache/sling/cli/impl/release/UpdateReporterCommand.java
+++ b/src/main/java/org/apache/sling/cli/impl/release/UpdateReporterCommand.java
@@ -37,7 +37,6 @@ import org.apache.sling.cli.impl.InputOption;
 import org.apache.sling.cli.impl.UserInput;
 import org.apache.sling.cli.impl.http.HttpClientFactory;
 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;
@@ -69,18 +68,37 @@ public class UpdateReporterCommand implements Command {
 
     @CommandLine.Option(
             names = {"-r", "--repository"},
-            description = "Nexus repository id",
-            required = true)
+            description = "Nexus staging repository id to derive the 
release(s) from")
     private Integer repositoryId;
 
+    @CommandLine.Option(
+            names = {"--release"},
+            description = "Release name(s) to act on, e.g. \"Apache Sling Foo 
1.2.0\" (comma-separated for multiple)."
+                    + " Use instead of --repository when the staging 
repository no longer exists,"
+                    + " e.g. after the release has been promoted.")
+    private String releaseName;
+
     @CommandLine.Mixin
     private ReusableCLIOptions reusableCLIOptions;
 
+    private Set<Release> resolveReleases() throws IOException {
+        if (releaseName != null && !releaseName.isBlank()) {
+            return Set.copyOf(Release.fromString(releaseName));
+        }
+        if (repositoryId == null) {
+            return Set.of();
+        }
+        return 
repositoryService.getReleases(repositoryService.find(repositoryId));
+    }
+
     @Override
     public Integer call() {
         try {
-            StagingRepository repository = 
repositoryService.find(repositoryId);
-            Set<Release> releases = repositoryService.getReleases(repository);
+            Set<Release> releases = resolveReleases();
+            if (releases.isEmpty()) {
+                LOGGER.error("Provide either --repository or --release.");
+                return CommandLine.ExitCode.USAGE;
+            }
             String releaseReleases = releases.size() > 1 ? "releases" : 
"release";
             switch (reusableCLIOptions.executionMode) {
                 case DRY_RUN:
diff --git 
a/src/test/java/org/apache/sling/cli/impl/release/CreateJiraVersionCommandTest.java
 
b/src/test/java/org/apache/sling/cli/impl/release/CreateJiraVersionCommandTest.java
new file mode 100644
index 0000000..3e23847
--- /dev/null
+++ 
b/src/test/java/org/apache/sling/cli/impl/release/CreateJiraVersionCommandTest.java
@@ -0,0 +1,217 @@
+/*
+ * 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 org.apache.commons.lang3.reflect.FieldUtils;
+import org.apache.sling.cli.impl.Command;
+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.jira.Issue;
+import org.apache.sling.cli.impl.jira.Version;
+import org.apache.sling.cli.impl.jira.VersionClient;
+import org.apache.sling.cli.impl.junit.LogCapture;
+import org.apache.sling.cli.impl.nexus.RepositoryService;
+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.anyString;
+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.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+public class CreateJiraVersionCommandTest {
+
+    private static final String VERSION_NAME = "Apache Sling CLI Test 1.0.0";
+
+    @Rule
+    public final OsgiContext osgiContext = new OsgiContext();
+
+    @Rule
+    public final LogCapture logCapture = new 
LogCapture(CreateJiraVersionCommand.class);
+
+    private VersionClient versionClient;
+
+    private void prepare() {
+        versionClient = mock(VersionClient.class);
+        osgiContext.registerService(VersionClient.class, versionClient);
+        osgiContext.registerService(RepositoryService.class, 
mock(RepositoryService.class));
+    }
+
+    @Test
+    public void testDryRunCreatesNothing() throws Exception {
+        prepare();
+        Version version = mock(Version.class);
+        when(version.getName()).thenReturn("1.0.0");
+        when(versionClient.find(any())).thenReturn(version);
+        when(versionClient.findSuccessorVersion(any())).thenReturn(null);
+        when(versionClient.findUnresolvedIssues(any())).thenReturn(List.of());
+
+        Command command = createCommand(ExecutionMode.DRY_RUN, VERSION_NAME);
+        assertEquals(CommandLine.ExitCode.OK, (int) command.call());
+
+        verify(versionClient, never()).create(anyString());
+        assertTrue(logCapture.containsMessage("would be created"));
+    }
+
+    @Test
+    public void testAutoCreatesSuccessor() throws Exception {
+        prepare();
+        Version version = mock(Version.class);
+        when(version.getName()).thenReturn("1.0.0");
+        Version successor = mock(Version.class);
+        when(successor.getName()).thenReturn("1.0.2");
+        when(versionClient.find(any())).thenReturn(version);
+        when(versionClient.findSuccessorVersion(any())).thenReturn(null, 
successor);
+        when(versionClient.findUnresolvedIssues(any())).thenReturn(List.of());
+
+        Command command = createCommand(ExecutionMode.AUTO, VERSION_NAME);
+        assertEquals(CommandLine.ExitCode.OK, (int) command.call());
+
+        verify(versionClient, times(1)).create(anyString());
+    }
+
+    @Test
+    public void testNoRepositoryNoReleaseReturnsUsage() throws Exception {
+        prepare();
+        Command command = createCommandWithoutRepository(ExecutionMode.AUTO);
+        assertEquals(CommandLine.ExitCode.USAGE, (int) command.call());
+        assertTrue(logCapture.containsMessage("Provide either --repository or 
--release."));
+        verifyNoInteractions(versionClient);
+    }
+
+    @Test
+    public void testAutoMovesUnresolvedIssues() throws Exception {
+        prepare();
+        Version version = mock(Version.class);
+        when(version.getName()).thenReturn("1.0.0");
+        Version successor = mock(Version.class);
+        when(successor.getName()).thenReturn("1.0.2");
+        Issue issue = mock(Issue.class);
+        when(issue.getKey()).thenReturn("SLING-123");
+        when(issue.getSummary()).thenReturn("Some bug");
+        when(versionClient.find(any())).thenReturn(version);
+        when(versionClient.findSuccessorVersion(any())).thenReturn(successor);
+        
when(versionClient.findUnresolvedIssues(any())).thenReturn(List.of(issue));
+
+        Command command = createCommand(ExecutionMode.AUTO, VERSION_NAME);
+        assertEquals(CommandLine.ExitCode.OK, (int) command.call());
+
+        verify(versionClient, never()).create(anyString());
+        verify(versionClient, times(1)).moveIssuesToNewVersion(any(), any(), 
any());
+    }
+
+    @Test
+    public void testInteractiveCreatesAndMovesOnYes() throws Exception {
+        prepare();
+        Version version = mock(Version.class);
+        when(version.getName()).thenReturn("1.0.0");
+        Version successor = mock(Version.class);
+        when(successor.getName()).thenReturn("1.0.2");
+        Issue issue = mock(Issue.class);
+        when(issue.getKey()).thenReturn("SLING-123");
+        when(issue.getSummary()).thenReturn("Some bug");
+        when(versionClient.find(any())).thenReturn(version);
+        when(versionClient.findSuccessorVersion(any())).thenReturn(null, 
successor);
+        
when(versionClient.findUnresolvedIssues(any())).thenReturn(List.of(issue));
+
+        try (MockedStatic<UserInput> userInput = mockStatic(UserInput.class)) {
+            userInput.when(() -> UserInput.yesNo(anyString(), 
any())).thenReturn(InputOption.YES);
+            Command command = createCommand(ExecutionMode.INTERACTIVE, 
VERSION_NAME);
+            assertEquals(CommandLine.ExitCode.OK, (int) command.call());
+            verify(versionClient, times(1)).create(anyString());
+            verify(versionClient, times(1)).moveIssuesToNewVersion(any(), 
any(), any());
+        }
+    }
+
+    @Test
+    public void testInteractiveSkipsOnNo() throws Exception {
+        prepare();
+        Version version = mock(Version.class);
+        when(version.getName()).thenReturn("1.0.0");
+        Version successor = mock(Version.class);
+        when(successor.getName()).thenReturn("1.0.2");
+        Issue issue = mock(Issue.class);
+        when(versionClient.find(any())).thenReturn(version);
+        when(versionClient.findSuccessorVersion(any())).thenReturn(successor);
+        
when(versionClient.findUnresolvedIssues(any())).thenReturn(List.of(issue));
+
+        try (MockedStatic<UserInput> userInput = mockStatic(UserInput.class)) {
+            userInput.when(() -> UserInput.yesNo(anyString(), 
any())).thenReturn(InputOption.NO);
+            Command command = createCommand(ExecutionMode.INTERACTIVE, 
VERSION_NAME);
+            assertEquals(CommandLine.ExitCode.OK, (int) command.call());
+            verify(versionClient, never()).moveIssuesToNewVersion(any(), 
any(), any());
+        }
+    }
+
+    @Test
+    public void testIOExceptionReturnsSoftware() throws Exception {
+        // releases() resolves from the staging repository; an IOException 
there surfaces as SOFTWARE
+        versionClient = mock(VersionClient.class);
+        osgiContext.registerService(VersionClient.class, versionClient);
+        RepositoryService repositoryService = mock(RepositoryService.class);
+        when(repositoryService.find(123)).thenThrow(new 
java.io.IOException("nexus down"));
+        osgiContext.registerService(RepositoryService.class, 
repositoryService);
+
+        Command command = createCommand(ExecutionMode.AUTO, null);
+        assertEquals(CommandLine.ExitCode.SOFTWARE, (int) command.call());
+        assertTrue(logCapture.containsMessage("Failed executing command"));
+    }
+
+    private Command createCommandWithoutRepository(ExecutionMode 
executionMode) throws IllegalAccessException {
+        CreateJiraVersionCommand createJiraVersionCommand = spy(new 
CreateJiraVersionCommand());
+        ReusableCLIOptions reusableCLIOptions = mock(ReusableCLIOptions.class);
+        FieldUtils.writeField(reusableCLIOptions, "executionMode", 
executionMode, true);
+        FieldUtils.writeField(createJiraVersionCommand, "reusableCLIOptions", 
reusableCLIOptions, true);
+        osgiContext.registerInjectActivateService(createJiraVersionCommand);
+        Command result = osgiContext.getService(Command.class);
+        assertTrue(
+                "Expected to retrieve the CreateJiraVersionCommand from the 
mocked OSGi environment.",
+                result instanceof CreateJiraVersionCommand);
+        return result;
+    }
+
+    private Command createCommand(ExecutionMode executionMode, String 
jiraVersionName) throws IllegalAccessException {
+        CreateJiraVersionCommand createJiraVersionCommand = spy(new 
CreateJiraVersionCommand());
+        FieldUtils.writeField(createJiraVersionCommand, "repositoryId", 123, 
true);
+        FieldUtils.writeField(createJiraVersionCommand, "jiraVersionName", 
jiraVersionName, true);
+        ReusableCLIOptions reusableCLIOptions = mock(ReusableCLIOptions.class);
+        FieldUtils.writeField(reusableCLIOptions, "executionMode", 
executionMode, true);
+        FieldUtils.writeField(createJiraVersionCommand, "reusableCLIOptions", 
reusableCLIOptions, true);
+        osgiContext.registerInjectActivateService(createJiraVersionCommand);
+        Command result = osgiContext.getService(Command.class);
+        assertTrue(
+                "Expected to retrieve the CreateJiraVersionCommand from the 
mocked OSGi environment.",
+                result instanceof CreateJiraVersionCommand);
+        return result;
+    }
+}
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..9d8324c
--- /dev/null
+++ b/src/test/java/org/apache/sling/cli/impl/release/FinalizeCommandTest.java
@@ -0,0 +1,235 @@
+/*
+ * 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.listDistFiles(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.commitDistUpdate(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.listDistFiles(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.commitDistUpdate(
+                    eq("org.apache.sling.cli.test"), eq("1.0.0"), any(), 
any(), any()));
+            verify(versionClient).release(any());
+        }
+    }
+
+    @Test
+    public void testReporterFailureReturnsSoftware() throws Exception {
+        prepare(false);
+        // the reporter POST now returns a non-200 status, which must surface 
as a SOFTWARE exit code
+        StatusLine statusLine = mock(StatusLine.class);
+        when(statusLine.getStatusCode()).thenReturn(500);
+        CloseableHttpResponse response = mock(CloseableHttpResponse.class);
+        when(response.getStatusLine()).thenReturn(statusLine);
+        when(client.execute(any())).thenReturn(response);
+
+        Command command = createCommand(123, ExecutionMode.AUTO);
+        assertEquals(CommandLine.ExitCode.SOFTWARE, (int) command.call());
+        assertTrue(logCapture.containsMessage("Failed executing command"));
+    }
+
+    @Test
+    public void testAutoCreatesNextJiraVersionAndMovesIssues() throws 
Exception {
+        prepare(false);
+        // no successor exists yet -> the next version is created; after 
creation a successor is found
+        org.apache.sling.cli.impl.jira.Version successor = 
mock(org.apache.sling.cli.impl.jira.Version.class);
+        when(successor.getName()).thenReturn("CLI Test 1.0.2");
+        when(versionClient.findSuccessorVersion(any())).thenReturn(null, 
successor);
+        org.apache.sling.cli.impl.jira.Issue issue = 
mock(org.apache.sling.cli.impl.jira.Issue.class);
+        
when(versionClient.findUnresolvedIssues(any())).thenReturn(List.of(issue));
+
+        Command command = createCommand(123, ExecutionMode.AUTO);
+        assertEquals(CommandLine.ExitCode.OK, (int) command.call());
+
+        verify(versionClient).create(any());
+        verify(versionClient).moveIssuesToNewVersion(any(), any(), any());
+    }
+
+    @Test
+    public void testDryRunDescribesNextJiraVersionAndIssues() throws Exception 
{
+        prepare(false);
+        when(versionClient.findSuccessorVersion(any())).thenReturn(null);
+        org.apache.sling.cli.impl.jira.Issue issue = 
mock(org.apache.sling.cli.impl.jira.Issue.class);
+        
when(versionClient.findUnresolvedIssues(any())).thenReturn(List.of(issue));
+
+        Command command = createCommand(123, ExecutionMode.DRY_RUN);
+        assertEquals(CommandLine.ExitCode.OK, (int) command.call());
+
+        // dry-run must not actually create versions or move issues
+        verify(versionClient, never()).create(any());
+        verify(versionClient, never()).moveIssuesToNewVersion(any(), any(), 
any());
+        assertTrue(logCapture.containsMessage("Would create JIRA version CLI 
Test 1.0.2"));
+    }
+
+    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/ReleaseJiraVersionCommandTest.java
 
b/src/test/java/org/apache/sling/cli/impl/release/ReleaseJiraVersionCommandTest.java
new file mode 100644
index 0000000..d7715da
--- /dev/null
+++ 
b/src/test/java/org/apache/sling/cli/impl/release/ReleaseJiraVersionCommandTest.java
@@ -0,0 +1,152 @@
+/*
+ * 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.ExecutionMode;
+import org.apache.sling.cli.impl.InputOption;
+import org.apache.sling.cli.impl.UserInput;
+import org.apache.sling.cli.impl.jira.Issue;
+import org.apache.sling.cli.impl.jira.VersionClient;
+import org.apache.sling.cli.impl.junit.LogCapture;
+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.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyString;
+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.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+public class ReleaseJiraVersionCommandTest {
+
+    @Rule
+    public final OsgiContext osgiContext = new OsgiContext();
+
+    @Rule
+    public final LogCapture logCapture = new 
LogCapture(ReleaseJiraVersionCommand.class);
+
+    private RepositoryService repositoryService;
+    private VersionClient versionClient;
+
+    private void prepare() throws Exception {
+        StagingRepository stagingRepository = mock(StagingRepository.class);
+        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);
+
+        Issue issue = mock(Issue.class);
+        when(issue.getKey()).thenReturn("SLING-1");
+        when(issue.getSummary()).thenReturn("A fixed issue");
+        when(issue.getStatus()).thenReturn("Closed");
+        when(issue.getResolution()).thenReturn("Fixed");
+
+        versionClient = mock(VersionClient.class);
+        when(versionClient.findFixedIssues(any())).thenReturn(List.of(issue));
+
+        osgiContext.registerService(RepositoryService.class, 
repositoryService);
+        osgiContext.registerService(VersionClient.class, versionClient);
+    }
+
+    @Test
+    public void testDryRun() throws Exception {
+        prepare();
+        Command command = createCommand(123, ExecutionMode.DRY_RUN);
+        assertEquals(CommandLine.ExitCode.OK, (int) command.call());
+        verify(versionClient, never()).release(any());
+        assertTrue(logCapture.containsMessage("The following Jira versions 
would be released:"));
+    }
+
+    @Test
+    public void testInteractiveYes() throws Exception {
+        prepare();
+        try (MockedStatic<UserInput> userInputMock = 
mockStatic(UserInput.class)) {
+            userInputMock
+                    .when(() -> UserInput.yesNo(anyString(), 
any(InputOption.class)))
+                    .thenReturn(InputOption.YES);
+            Command command = createCommand(123, ExecutionMode.INTERACTIVE);
+            assertEquals(CommandLine.ExitCode.OK, (int) command.call());
+            verify(versionClient, times(1)).release(any());
+        }
+    }
+
+    @Test
+    public void testAuto() throws Exception {
+        prepare();
+        Command command = createCommand(123, ExecutionMode.AUTO);
+        assertEquals(CommandLine.ExitCode.OK, (int) command.call());
+        verify(versionClient, times(1)).release(any());
+    }
+
+    @Test
+    public void testReleaseByName() throws Exception {
+        // the release is resolved from --release, so the command works 
without the (now-gone) staging repo
+        prepare();
+        Command command = createCommandByName("Apache Sling CLI Test 1.0.0", 
ExecutionMode.AUTO);
+        assertEquals(CommandLine.ExitCode.OK, (int) command.call());
+        verify(versionClient, times(1)).release(any());
+        verify(repositoryService, never()).find(anyInt());
+    }
+
+    private Command createCommand(int repositoryId, ExecutionMode 
executionMode) throws Exception {
+        ReleaseJiraVersionCommand releaseJiraVersionCommand = spy(new 
ReleaseJiraVersionCommand());
+        FieldUtils.writeField(releaseJiraVersionCommand, "repositoryId", 
repositoryId, true);
+        ReusableCLIOptions reusableCLIOptions = mock(ReusableCLIOptions.class);
+        FieldUtils.writeField(reusableCLIOptions, "executionMode", 
executionMode, true);
+        FieldUtils.writeField(releaseJiraVersionCommand, "reusableCLIOptions", 
reusableCLIOptions, true);
+        osgiContext.registerInjectActivateService(releaseJiraVersionCommand);
+        Command result = osgiContext.getService(Command.class);
+        assertTrue(
+                "Expected to retrieve the ReleaseJiraVersionCommand from the 
mocked OSGi environment.",
+                result instanceof ReleaseJiraVersionCommand);
+        return result;
+    }
+
+    private Command createCommandByName(String releaseName, ExecutionMode 
executionMode) throws Exception {
+        ReleaseJiraVersionCommand releaseJiraVersionCommand = spy(new 
ReleaseJiraVersionCommand());
+        FieldUtils.writeField(releaseJiraVersionCommand, "releaseName", 
releaseName, true);
+        ReusableCLIOptions reusableCLIOptions = mock(ReusableCLIOptions.class);
+        FieldUtils.writeField(reusableCLIOptions, "executionMode", 
executionMode, true);
+        FieldUtils.writeField(releaseJiraVersionCommand, "reusableCLIOptions", 
reusableCLIOptions, true);
+        osgiContext.registerInjectActivateService(releaseJiraVersionCommand);
+        Command result = osgiContext.getService(Command.class);
+        assertTrue(
+                "Expected to retrieve the ReleaseJiraVersionCommand from the 
mocked OSGi environment.",
+                result instanceof ReleaseJiraVersionCommand);
+        return result;
+    }
+}
diff --git 
a/src/test/java/org/apache/sling/cli/impl/release/UpdateLocalSiteCommandTest.java
 
b/src/test/java/org/apache/sling/cli/impl/release/UpdateLocalSiteCommandTest.java
new file mode 100644
index 0000000..f034fb6
--- /dev/null
+++ 
b/src/test/java/org/apache/sling/cli/impl/release/UpdateLocalSiteCommandTest.java
@@ -0,0 +1,154 @@
+/*
+ * 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.Set;
+
+import org.apache.commons.lang3.reflect.FieldUtils;
+import org.apache.sling.cli.impl.Command;
+import org.apache.sling.cli.impl.jbake.JBakeContentUpdater;
+import org.apache.sling.cli.impl.junit.LogCapture;
+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.eclipse.jgit.api.CloneCommand;
+import org.eclipse.jgit.api.DiffCommand;
+import org.eclipse.jgit.api.Git;
+import org.eclipse.jgit.api.ResetCommand;
+import org.junit.Rule;
+import org.junit.Test;
+import org.mockito.MockedConstruction;
+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.atLeastOnce;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockConstruction;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+public class UpdateLocalSiteCommandTest {
+
+    @Rule
+    public final OsgiContext osgiContext = new OsgiContext();
+
+    @Rule
+    public final LogCapture logCapture = new 
LogCapture(UpdateLocalSiteCommand.class);
+
+    /**
+     * Stubs out the JGit interactions so that no repository is cloned, opened 
or reset against the
+     * real filesystem or network. The {@code Git} instance returned by {@code 
Git.open(...)} is a
+     * mock whose {@code diff()} returns a mock {@link DiffCommand}.
+     */
+    private MockedStatic<Git> stubGit() {
+        MockedStatic<Git> git = mockStatic(Git.class);
+        Git gitInstance = mock(Git.class);
+        // ensureRepo: the checkout already exists -> 
Git.open(...).reset()...call()
+        ResetCommand resetCommand = mock(ResetCommand.class);
+        when(resetCommand.setMode(any())).thenReturn(resetCommand);
+        when(gitInstance.reset()).thenReturn(resetCommand);
+        // call(): git.diff().setOutputStream(...).call()
+        DiffCommand diffCommand = mock(DiffCommand.class);
+        when(diffCommand.setOutputStream(any())).thenReturn(diffCommand);
+        when(gitInstance.diff()).thenReturn(diffCommand);
+        git.when(() -> Git.open(any())).thenReturn(gitInstance);
+        // ensureRepo: when the checkout does not yet exist, it is cloned 
instead
+        CloneCommand cloneCommand = mock(CloneCommand.class);
+        when(cloneCommand.setURI(any())).thenReturn(cloneCommand);
+        when(cloneCommand.setProgressMonitor(any())).thenReturn(cloneCommand);
+        when(cloneCommand.setDirectory(any())).thenReturn(cloneCommand);
+        git.when(Git::cloneRepository).thenReturn(cloneCommand);
+        return git;
+    }
+
+    @Test
+    public void testNoRepositoryNoReleaseReturnsUsage() throws Exception {
+        osgiContext.registerService(RepositoryService.class, 
mock(RepositoryService.class));
+        try (MockedStatic<Git> git = stubGit()) {
+            Command command = createCommand(null, null);
+            assertEquals(CommandLine.ExitCode.USAGE, (int) command.call());
+            assertTrue(logCapture.containsMessage("Provide either --repository 
or --release."));
+        }
+    }
+
+    @Test
+    public void testReleaseNameUpdatesContent() throws Exception {
+        osgiContext.registerService(RepositoryService.class, 
mock(RepositoryService.class));
+        try (MockedStatic<Git> git = stubGit();
+                MockedConstruction<JBakeContentUpdater> updater = 
mockConstruction(JBakeContentUpdater.class)) {
+            Command command = createCommand(null, "Apache Sling Foo 1.2.0");
+            assertEquals(CommandLine.ExitCode.OK, (int) command.call());
+
+            JBakeContentUpdater instance = updater.constructed().get(0);
+            verify(instance).updateDownloads(any(), eq("Foo"), eq("1.2.0"));
+            verify(instance).updateReleases(any(), eq("Foo"), eq("1.2.0"), 
any());
+        }
+    }
+
+    @Test
+    public void testRepositoryResolvesReleasesFromService() throws Exception {
+        RepositoryService repositoryService = mock(RepositoryService.class);
+        StagingRepository repository = mock(StagingRepository.class);
+        when(repositoryService.find(123)).thenReturn(repository);
+        when(repositoryService.getReleases(repository))
+                .thenReturn(Set.copyOf(Release.fromString("Apache Sling Bar 
2.0.0")));
+        osgiContext.registerService(RepositoryService.class, 
repositoryService);
+
+        try (MockedStatic<Git> git = stubGit();
+                MockedConstruction<JBakeContentUpdater> updater = 
mockConstruction(JBakeContentUpdater.class)) {
+            Command command = createCommand(123, null);
+            assertEquals(CommandLine.ExitCode.OK, (int) command.call());
+
+            JBakeContentUpdater instance = updater.constructed().get(0);
+            verify(instance, atLeastOnce()).updateDownloads(any(), eq("Bar"), 
eq("2.0.0"));
+        }
+    }
+
+    @Test
+    public void testIOExceptionReturnsSoftware() throws Exception {
+        RepositoryService repositoryService = mock(RepositoryService.class);
+        when(repositoryService.find(123)).thenThrow(new IOException("nexus 
down"));
+        osgiContext.registerService(RepositoryService.class, 
repositoryService);
+
+        try (MockedStatic<Git> git = stubGit()) {
+            Command command = createCommand(123, null);
+            assertEquals(CommandLine.ExitCode.SOFTWARE, (int) command.call());
+            assertTrue(logCapture.containsMessage("Failed executing command"));
+        }
+    }
+
+    private Command createCommand(Integer repositoryId, String releaseName) 
throws IllegalAccessException {
+        UpdateLocalSiteCommand updateLocalSiteCommand = spy(new 
UpdateLocalSiteCommand());
+        FieldUtils.writeField(updateLocalSiteCommand, "repositoryId", 
repositoryId, true);
+        FieldUtils.writeField(updateLocalSiteCommand, "releaseName", 
releaseName, true);
+        osgiContext.registerInjectActivateService(updateLocalSiteCommand);
+        Command result = osgiContext.getService(Command.class);
+        assertTrue(
+                "Expected to retrieve the UpdateLocalSiteCommand from the 
mocked OSGi environment.",
+                result instanceof UpdateLocalSiteCommand);
+        return result;
+    }
+}
diff --git 
a/src/test/java/org/apache/sling/cli/impl/release/UpdateReporterCommandTest.java
 
b/src/test/java/org/apache/sling/cli/impl/release/UpdateReporterCommandTest.java
index 10f53fa..fba8cc5 100644
--- 
a/src/test/java/org/apache/sling/cli/impl/release/UpdateReporterCommandTest.java
+++ 
b/src/test/java/org/apache/sling/cli/impl/release/UpdateReporterCommandTest.java
@@ -118,6 +118,43 @@ public class UpdateReporterCommandTest {
         verify(client, times(2)).execute(any());
     }
 
+    @Test
+    public void testAutoByName() throws Exception {
+        // resolves the release from --release rather than a (possibly gone) 
staging repository
+        Command updateReporter = createCommandByName("Apache Sling CLI 1", 
ExecutionMode.AUTO);
+        CloseableHttpResponse response = mock(CloseableHttpResponse.class);
+        StatusLine statusLine = mock(StatusLine.class);
+        when(response.getStatusLine()).thenReturn(statusLine);
+        when(statusLine.getStatusCode()).thenReturn(200);
+        when(client.execute(any())).thenReturn(response);
+        assertEquals(0, (int) updateReporter.call());
+        verify(client, times(1)).execute(any());
+    }
+
+    @Test
+    public void testNoRepositoryNoReleaseReturnsUsage() throws Exception {
+        UpdateReporterCommand updateReporterCommand = spy(new 
UpdateReporterCommand());
+        ReusableCLIOptions reusableCLIOptions = mock(ReusableCLIOptions.class);
+        FieldUtils.writeField(reusableCLIOptions, "executionMode", 
ExecutionMode.AUTO, true);
+        FieldUtils.writeField(updateReporterCommand, "reusableCLIOptions", 
reusableCLIOptions, true);
+        osgiContext.registerInjectActivateService(updateReporterCommand);
+        Command command = osgiContext.getService(Command.class);
+        assertEquals(2, (int) command.call());
+        assertTrue(logCapture.containsMessage("Provide either --repository or 
--release."));
+        verifyNoInteractions(client);
+    }
+
+    @Test
+    public void testReporterFailureReturnsSoftware() throws Exception {
+        Command updateReporter = createCommand(42, ExecutionMode.AUTO);
+        CloseableHttpResponse response = mock(CloseableHttpResponse.class);
+        StatusLine statusLine = mock(StatusLine.class);
+        when(response.getStatusLine()).thenReturn(statusLine);
+        when(statusLine.getStatusCode()).thenReturn(500);
+        when(client.execute(any())).thenReturn(response);
+        assertEquals(1, (int) updateReporter.call());
+    }
+
     private Command createCommand(int repositoryId, ExecutionMode 
executionMode) throws IllegalAccessException {
         UpdateReporterCommand updateReporterCommand = spy(new 
UpdateReporterCommand());
         FieldUtils.writeField(updateReporterCommand, "repositoryId", 
repositoryId, true);
@@ -131,4 +168,18 @@ public class UpdateReporterCommandTest {
                 result instanceof UpdateReporterCommand);
         return result;
     }
+
+    private Command createCommandByName(String releaseName, ExecutionMode 
executionMode) throws IllegalAccessException {
+        UpdateReporterCommand updateReporterCommand = spy(new 
UpdateReporterCommand());
+        FieldUtils.writeField(updateReporterCommand, "releaseName", 
releaseName, true);
+        ReusableCLIOptions reusableCLIOptions = mock(ReusableCLIOptions.class);
+        FieldUtils.writeField(reusableCLIOptions, "executionMode", 
executionMode, true);
+        FieldUtils.writeField(updateReporterCommand, "reusableCLIOptions", 
reusableCLIOptions, true);
+        osgiContext.registerInjectActivateService(updateReporterCommand);
+        Command result = osgiContext.getService(Command.class);
+        assertTrue(
+                "Expected to retrieve the UpdateReporterCommand from the 
mocked OSGi environment.",
+                result instanceof UpdateReporterCommand);
+        return result;
+    }
 }
diff --git 
a/src/test/java/org/apache/sling/cli/impl/release/VerifyReleasesCommandTest.java
 
b/src/test/java/org/apache/sling/cli/impl/release/VerifyReleasesCommandTest.java
new file mode 100644
index 0000000..e7f606d
--- /dev/null
+++ 
b/src/test/java/org/apache/sling/cli/impl/release/VerifyReleasesCommandTest.java
@@ -0,0 +1,127 @@
+/*
+ * 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.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.Set;
+
+import org.apache.commons.lang3.reflect.FieldUtils;
+import org.apache.sling.cli.impl.Command;
+import org.apache.sling.cli.impl.ExecutionMode;
+import org.apache.sling.cli.impl.ci.CIStatusValidator;
+import org.apache.sling.cli.impl.junit.LogCapture;
+import org.apache.sling.cli.impl.nexus.Artifact;
+import org.apache.sling.cli.impl.nexus.LocalRepository;
+import org.apache.sling.cli.impl.nexus.RepositoryService;
+import org.apache.sling.cli.impl.nexus.StagingRepository;
+import org.apache.sling.cli.impl.pgp.HashValidator;
+import org.apache.sling.cli.impl.pgp.PGPSignatureValidator;
+import org.apache.sling.testing.mock.osgi.junit.OsgiContext;
+import org.junit.Rule;
+import org.junit.Test;
+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.Mockito.mock;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.when;
+
+public class VerifyReleasesCommandTest {
+
+    @Rule
+    public final OsgiContext osgiContext = new OsgiContext();
+
+    @Rule
+    public final LogCapture logCapture = new 
LogCapture(VerifyReleasesCommand.class);
+
+    @Test
+    public void testEmptyRepositoryIsValid() throws Exception {
+        // A downloaded repository with no artifacts runs no checks and is 
reported valid.
+        LocalRepository localRepository = mock(LocalRepository.class);
+        when(localRepository.getArtifacts()).thenReturn(Set.<Artifact>of());
+        when(localRepository.getRootFolder()).thenReturn(Paths.get("/tmp"));
+
+        RepositoryService repositoryService = mock(RepositoryService.class);
+        StagingRepository stagingRepository = mock(StagingRepository.class);
+        when(repositoryService.find(123)).thenReturn(stagingRepository);
+        
when(repositoryService.download(stagingRepository)).thenReturn(localRepository);
+
+        osgiContext.registerService(RepositoryService.class, 
repositoryService);
+        osgiContext.registerService(PGPSignatureValidator.class, 
mock(PGPSignatureValidator.class));
+        osgiContext.registerService(CIStatusValidator.class, 
mock(CIStatusValidator.class));
+        osgiContext.registerService(HashValidator.class, 
mock(HashValidator.class));
+
+        Command command = createCommand(123, ExecutionMode.DRY_RUN);
+        assertEquals(CommandLine.ExitCode.OK, (int) command.call());
+        assertTrue(logCapture.containsMessage("VALID (0 checks executed)"));
+    }
+
+    @Test
+    public void testInvalidSignatureFailsVerification() throws Exception {
+        // One artifact whose signature, SHA-1 and MD5 all fail -> command 
reports INVALID.
+        StagingRepository stagingRepository = mock(StagingRepository.class);
+        Artifact jar =
+                new Artifact(stagingRepository, "org.apache.sling", 
"org.apache.sling.cli.test", "1.0.0", null, "jar");
+        LocalRepository localRepository = mock(LocalRepository.class);
+        when(localRepository.getArtifacts()).thenReturn(Set.of(jar));
+        when(localRepository.getRootFolder()).thenReturn(Paths.get("/tmp"));
+
+        RepositoryService repositoryService = mock(RepositoryService.class);
+        when(repositoryService.find(123)).thenReturn(stagingRepository);
+        
when(repositoryService.download(stagingRepository)).thenReturn(localRepository);
+
+        PGPSignatureValidator pgp = mock(PGPSignatureValidator.class);
+        PGPSignatureValidator.ValidationResult pgpResult = 
mock(PGPSignatureValidator.ValidationResult.class);
+        when(pgpResult.isValid()).thenReturn(false);
+        when(pgp.verify(any(Path.class), 
any(Path.class))).thenReturn(pgpResult);
+
+        HashValidator hashValidator = mock(HashValidator.class);
+        HashValidator.ValidationResult hashResult = 
mock(HashValidator.ValidationResult.class);
+        when(hashResult.isValid()).thenReturn(false);
+        when(hashResult.getExpectedHash()).thenReturn("expected");
+        when(hashResult.getActualHash()).thenReturn("actual");
+        when(hashValidator.validate(any(Path.class), any(Path.class), 
any())).thenReturn(hashResult);
+
+        osgiContext.registerService(RepositoryService.class, 
repositoryService);
+        osgiContext.registerService(PGPSignatureValidator.class, pgp);
+        osgiContext.registerService(CIStatusValidator.class, 
mock(CIStatusValidator.class));
+        osgiContext.registerService(HashValidator.class, hashValidator);
+
+        Command command = createCommand(123, ExecutionMode.DRY_RUN);
+        assertEquals(CommandLine.ExitCode.USAGE, (int) command.call());
+        assertTrue(logCapture.containsMessage("INVALID"));
+    }
+
+    private Command createCommand(int repositoryId, ExecutionMode 
executionMode) throws IllegalAccessException {
+        VerifyReleasesCommand verifyReleasesCommand = spy(new 
VerifyReleasesCommand());
+        FieldUtils.writeField(verifyReleasesCommand, "repositoryId", 
repositoryId, true);
+        ReusableCLIOptions reusableCLIOptions = mock(ReusableCLIOptions.class);
+        FieldUtils.writeField(reusableCLIOptions, "executionMode", 
executionMode, true);
+        FieldUtils.writeField(verifyReleasesCommand, "reusableCLIOptions", 
reusableCLIOptions, true);
+        osgiContext.registerInjectActivateService(verifyReleasesCommand);
+        Command result = osgiContext.getService(Command.class);
+        assertTrue(
+                "Expected to retrieve the VerifyReleasesCommand from the 
mocked OSGi environment.",
+                result instanceof VerifyReleasesCommand);
+        return result;
+    }
+}

Reply via email to