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 2019/03/08 11:59:04 UTC

[sling-whiteboard] 01/02: SLING-8311 - Investigate creating a Sling CLI tool for development task automation

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

rombert pushed a commit to branch feature/SLING-8311
in repository https://gitbox.apache.org/repos/asf/sling-whiteboard.git

commit 1a0d2ee48e2d820c641ebeee3740ff9af14cb47b
Author: Robert Munteanu <ro...@apache.org>
AuthorDate: Fri Mar 8 13:56:17 2019 +0200

    SLING-8311 - Investigate creating a Sling CLI tool for development task automation
    
    Prototype of sling cli tool.
---
 cli/.gitignore                                     |   1 +
 cli/Dockerfile                                     |  30 ++++
 cli/README.md                                      |  23 +++
 cli/bnd.bnd                                        |   0
 cli/docker-env.sample                              |  15 ++
 cli/pom.xml                                        | 173 +++++++++++++++++++++
 cli/src/main/features/app.json                     |  67 ++++++++
 .../java/org/apache/sling/cli/impl/Command.java    |  26 ++++
 .../apache/sling/cli/impl/CommandProcessor.java    | 143 +++++++++++++++++
 .../apache/sling/cli/impl/ExecutionTrigger.java    |  37 +++++
 .../org/apache/sling/cli/impl/jira/Version.java    |  47 ++++++
 .../apache/sling/cli/impl/jira/VersionFinder.java  |  98 ++++++++++++
 .../sling/cli/impl/nexus/StagingRepositories.java  |  33 ++++
 .../sling/cli/impl/nexus/StagingRepository.java    |  65 ++++++++
 .../cli/impl/nexus/StagingRepositoryFinder.java    |  86 ++++++++++
 .../cli/impl/release/PrepareVoteEmailCommand.java  |  98 ++++++++++++
 .../sling/cli/impl/release/TallyVotesCommand.java  |  39 +++++
 cli/src/main/resources/conf/logback-default.xml    |  23 +++
 cli/src/main/resources/scripts/launcher.sh         |  29 ++++
 .../impl/release/PrepareVoteEmailCommandTest.java  |  31 ++++
 20 files changed, 1064 insertions(+)

diff --git a/cli/.gitignore b/cli/.gitignore
new file mode 100644
index 0000000..a49f72d
--- /dev/null
+++ b/cli/.gitignore
@@ -0,0 +1 @@
+docker-env
diff --git a/cli/Dockerfile b/cli/Dockerfile
new file mode 100644
index 0000000..1338fcb
--- /dev/null
+++ b/cli/Dockerfile
@@ -0,0 +1,30 @@
+# ----------------------------------------------------------------------------------------
+# 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.
+# ----------------------------------------------------------------------------------------
+
+FROM openjdk:8-jre-alpine
+MAINTAINER dev@sling.apache.org
+# escaping required to properly handle arguments with spaces
+ENTRYPOINT ["/usr/share/sling-cli/bin/launcher.sh"]
+
+# Add feature launcher
+ADD target/lib /usr/share/sling-cli/launcher
+# Add launcher script
+ADD target/classes/scripts /usr/share/sling-cli/bin
+# workaround for MRESOURCES-236
+RUN chmod a+x /usr/share/sling-cli/bin/*
+# Add config files
+ADD target/classes/conf /usr/share/sling-cli/conf
+# Add all bundles
+ADD target/artifacts /usr/share/sling-cli/artifacts
+# Add the service itself
+ARG FEATURE_FILE
+ADD ${FEATURE_FILE} /usr/share/sling-cli/sling-cli.feature
\ No newline at end of file
diff --git a/cli/README.md b/cli/README.md
new file mode 100644
index 0000000..eaa6d43
--- /dev/null
+++ b/cli/README.md
@@ -0,0 +1,23 @@
+# Apache Sling Engine CLI tool
+
+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.
+
+## Launching
+
+The image is built using `mvn package`. Afterwards it may be run with
+
+    docker run --env-file=./docker-env apache/sling-cli
+    
+This invocation produces a list of available subcommands.
+
+Currently the only implemented command is generating the release vote email, for instance
+
+    docker run --env-file=./docker-env apache/sling-cli release prepare-email $STAGING_REPOSITORY_ID
\ No newline at end of file
diff --git a/cli/bnd.bnd b/cli/bnd.bnd
new file mode 100644
index 0000000..e69de29
diff --git a/cli/docker-env.sample b/cli/docker-env.sample
new file mode 100644
index 0000000..15454cf
--- /dev/null
+++ b/cli/docker-env.sample
@@ -0,0 +1,15 @@
+#!/bin/sh
+# ----------------------------------------------------------------------------------------
+# 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.
+# ----------------------------------------------------------------------------------------
+ASF_USERNAME=changeme
+ASF_PASSWORD=changeme
+RELEASE_ID=42
diff --git a/cli/pom.xml b/cli/pom.xml
new file mode 100644
index 0000000..047121d
--- /dev/null
+++ b/cli/pom.xml
@@ -0,0 +1,173 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!-- 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. -->
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+
+    <parent>
+        <groupId>org.apache.sling</groupId>
+        <artifactId>sling</artifactId>
+        <version>34</version>
+        <relativePath />
+    </parent>
+
+    <artifactId>sling-cli</artifactId>
+    <version>1.0-SNAPSHOT</version>
+
+    <description>Sling CLI tool for development usage</description>
+
+    <properties>
+        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+    </properties>
+
+    <build>
+        <plugins>
+            <plugin>
+                <groupId>biz.aQute.bnd</groupId>
+                <artifactId>bnd-maven-plugin</artifactId>
+            </plugin>
+            <plugin>
+                <artifactId>maven-jar-plugin</artifactId>
+                <configuration>
+                    <archive>
+                        <manifest>
+                            <addClasspath>true</addClasspath>
+                            <classpathPrefix>lib/</classpathPrefix>
+                            <mainClass>org.apache.sling.cli.impl.Main</mainClass>
+                        </manifest>
+                    </archive>
+                </configuration>
+            </plugin>
+            <plugin>
+                <artifactId>maven-dependency-plugin</artifactId>
+                <executions>
+                    <execution>
+                        <phase>prepare-package</phase>
+                        <goals>
+                            <goal>copy-dependencies</goal>
+                        </goals>
+                        <configuration>
+                            <overWriteReleases>false</overWriteReleases>
+                            <includeScope>runtime</includeScope>
+                            <outputDirectory>${project.build.directory}/lib</outputDirectory>
+                            <stripVersion>true</stripVersion>
+                        </configuration>
+                    </execution>
+                </executions>
+            </plugin>
+            <plugin>
+                <groupId>org.apache.sling</groupId>
+                <artifactId>slingfeature-maven-plugin</artifactId>
+                <version>0.8.0</version>
+                <extensions>true</extensions>
+                <executions>
+                    <execution>
+                        <id>feature-dependencies</id>
+                        <goals>
+                            <goal>repository</goal>
+                        </goals>
+                    </execution>
+                    <execution>
+                        <id>extra-dependencies</id>
+                        <goals>
+                            <goal>repository</goal>
+                        </goals>
+                        <configuration>
+                            <repositories>
+                                <repository>
+                                    <embedArtifacts>
+                                        <embedArtifact>
+                                            <groupId>org.apache.felix</groupId>
+                                            <artifactId>org.apache.felix.framework</artifactId>
+                                            <version>6.0.2</version>
+                                        </embedArtifact>
+                                        <embedArtifact>
+                                            <groupId>org.apache.sling</groupId>
+                                            <artifactId>org.apache.sling.launchpad.api</artifactId>
+                                            <version>1.2.0</version>
+                                        </embedArtifact>
+                                    </embedArtifacts>
+                                </repository>
+                            </repositories>
+                        </configuration>
+                    </execution>
+                </executions>
+            </plugin>
+            <plugin>
+                <groupId>com.spotify</groupId>
+                <artifactId>dockerfile-maven-plugin</artifactId>
+                <version>1.4.10</version>
+                <executions>
+                    <execution>
+                        <id>default</id>
+                        <goals>
+                            <goal>build</goal>
+                        </goals>
+                    </execution>
+                </executions>
+                <configuration>
+                    <skipDockerInfo>true</skipDockerInfo> <!-- does not contain legal files -->
+                    <repository>apache/sling-cli</repository>
+                    <buildArgs>
+                        <FEATURE_FILE>target/artifacts/org/apache/sling/${project.artifactId}/${project.version}/${project.artifactId}-${project.version}-app.slingfeature</FEATURE_FILE>
+                    </buildArgs>
+                </configuration>
+            </plugin>
+        </plugins>
+    </build>
+
+    <dependencies>
+        <dependency>
+            <groupId>org.osgi</groupId>
+            <artifactId>org.osgi.service.component.annotations</artifactId>
+            <scope>provided</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.osgi</groupId>
+            <artifactId>org.osgi.service.metatype.annotations</artifactId>
+            <scope>provided</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.osgi</groupId>
+            <artifactId>osgi.core</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.sling</groupId>
+            <artifactId>org.apache.sling.feature.launcher</artifactId>
+            <version>0.8.0</version>
+            <scope>runtime</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.slf4j</groupId>
+            <artifactId>slf4j-api</artifactId>
+            <scope>provided</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.httpcomponents</groupId>
+            <artifactId>httpclient-osgi</artifactId>
+            <version>4.5.7</version>
+            <scope>provided</scope>
+        </dependency>
+        <dependency>
+            <groupId>com.google.code.gson</groupId>
+            <artifactId>gson</artifactId>
+            <version>2.8.5</version>
+            <scope>provided</scope>
+        </dependency>
+        
+        <dependency>
+            <groupId>junit</groupId>
+            <artifactId>junit</artifactId>
+        </dependency>
+    </dependencies>
+</project>
diff --git a/cli/src/main/features/app.json b/cli/src/main/features/app.json
new file mode 100644
index 0000000..f07827d
--- /dev/null
+++ b/cli/src/main/features/app.json
@@ -0,0 +1,67 @@
+{
+	"id": "${project.groupId}:${project.artifactId}:slingfeature:app:${project.version}",
+	"variables": {
+    	"asf.username":"change-me",
+    	"asf.password": "change-me"
+    },
+	"bundles": [
+		{
+			"id": "${project.groupId}:${project.artifactId}:${project.version}",
+			"start-level": "5"
+		},
+		{
+			"id": "org.apache.felix:org.apache.felix.scr:2.1.12",
+			"start-level": "1"
+		},
+		{
+			"id": "org.apache.felix:org.apache.felix.configadmin:1.9.10",
+			"start-level": "1"
+		},
+		{
+			"id": "org.apache.felix:org.apache.felix.log:1.2.0",
+			"start-level": "1"
+		},
+		{
+			"id": "ch.qos.logback:logback-classic:1.2.3",
+			"start-level": "1"
+		},
+		{
+			"id": "ch.qos.logback:logback-core:1.2.3",
+			"start-level": "1"
+		},
+		{
+			"id": "org.slf4j:jul-to-slf4j:1.7.25",
+			"start-level": "1"
+		},
+		{
+			"id": "org.slf4j:jcl-over-slf4j:1.7.25",
+			"start-level": "1"
+		},
+		{
+			"id": "org.slf4j:slf4j-api:1.7.25",
+			"start-level": "1"
+		},
+		{
+			"id": "org.apache.felix:org.apache.felix.logback:1.0.2",
+			"start-level": "1"
+		},
+		{
+			"id": "org.apache.httpcomponents:httpcore-osgi:4.4.11",
+			"start-level": "3"
+		},
+		{
+			"id": "org.apache.httpcomponents:httpclient-osgi:4.5.7",
+			"start-level": "3"
+		},
+		{
+			"id": "com.google.code.gson:gson:2.8.5",
+			"start-level": "3"
+		}
+	],
+	"configurations": {
+		"org.apache.sling.cli.impl.nexus.StagingRepositoryFinder": {
+			"username": "${asf.username}",
+			"password": "${asf.password}"
+		}
+	}
+}
\ No newline at end of file
diff --git a/cli/src/main/java/org/apache/sling/cli/impl/Command.java b/cli/src/main/java/org/apache/sling/cli/impl/Command.java
new file mode 100644
index 0000000..4caeea7
--- /dev/null
+++ b/cli/src/main/java/org/apache/sling/cli/impl/Command.java
@@ -0,0 +1,26 @@
+/*
+ * 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;
+
+public interface Command {
+    
+    String PROPERTY_NAME_COMMAND = "command";
+    String PROPERTY_NAME_SUBCOMMAND = "subcommand";
+    String PROPERTY_NAME_SUMMARY = "summary";
+
+    void execute(String target);
+}
diff --git a/cli/src/main/java/org/apache/sling/cli/impl/CommandProcessor.java b/cli/src/main/java/org/apache/sling/cli/impl/CommandProcessor.java
new file mode 100644
index 0000000..57e5430
--- /dev/null
+++ b/cli/src/main/java/org/apache/sling/cli/impl/CommandProcessor.java
@@ -0,0 +1,143 @@
+/*
+ * 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;
+
+import static org.osgi.service.component.annotations.ReferenceCardinality.MULTIPLE;
+import static org.osgi.service.component.annotations.ReferencePolicy.DYNAMIC;
+
+import java.util.Map;
+import java.util.Objects;
+import java.util.concurrent.ConcurrentHashMap;
+
+import org.osgi.framework.BundleContext;
+import org.osgi.framework.BundleException;
+import org.osgi.framework.Constants;
+import org.osgi.framework.launch.Framework;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.Reference;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@Component(service = CommandProcessor.class)
+public class CommandProcessor {
+
+    private final Logger logger = LoggerFactory.getLogger(getClass());
+    private BundleContext ctx;
+
+    private Map<CommandKey, CommandWithProps> commands = new ConcurrentHashMap<>();
+
+    protected void activate(BundleContext ctx) {
+        this.ctx = ctx;
+    }
+
+    @Reference(service = Command.class, cardinality = MULTIPLE, policy = DYNAMIC)
+    protected void bindCommand(Command cmd, Map<String, ?> props) {
+        commands.put(CommandKey.of(props), CommandWithProps.of(cmd, props));
+    }
+
+    protected void unbindCommand(Map<String, ?> props) {
+        commands.remove(CommandKey.of(props));
+    }
+
+    public void runCommand() {
+        // TODO - remove duplication from CLI parsing code
+        CommandKey key = CommandKey.of(ctx.getProperty("exec.args"));
+        String target = parseTarget(ctx.getProperty("exec.args"));
+        commands.getOrDefault(key, new CommandWithProps(ignored -> {
+            logger.info("Usage: sling command sub-command [target]");
+            logger.info("");
+            logger.info("Available commands:");
+            commands.forEach((k, c) -> logger.info("{} {}: {}", k.command, k.subCommand, c.summary));
+        }, "")).cmd.execute(target);
+        try {
+            ctx.getBundle(Constants.SYSTEM_BUNDLE_LOCATION).adapt(Framework.class).stop();
+        } catch (BundleException e) {
+            logger.warn("Failed running command", e);
+        }
+    }
+
+    private String parseTarget(String cliSpec) {
+        if (cliSpec == null || cliSpec.isEmpty())
+            return null;
+
+        String[] args = cliSpec.split(" ");
+        if (args.length < 3)
+            return null;
+        
+        return args[2];
+    }
+    
+
+    static class CommandKey {
+
+        private static final CommandKey EMPTY = new CommandKey("", "");
+
+        private final String command;
+        private final String subCommand;
+
+        static CommandKey of(String cliSpec) {
+            if (cliSpec == null || cliSpec.isEmpty())
+                return EMPTY;
+
+            String[] args = cliSpec.split(" ");
+            if (args.length < 2)
+                return EMPTY;
+
+            return new CommandKey(args[0], args[1]);
+        }
+
+        static CommandKey of(Map<String, ?> serviceProps) {
+            return new CommandKey((String) serviceProps.get(Command.PROPERTY_NAME_COMMAND), (String) serviceProps.get(Command.PROPERTY_NAME_SUBCOMMAND));
+        }
+
+        CommandKey(String command, String subCommand) {
+            this.command = command;
+            this.subCommand = subCommand;
+        }
+
+        @Override
+        public int hashCode() {
+            return Objects.hash(command, subCommand);
+        }
+
+        @Override
+        public boolean equals(Object obj) {
+            if (this == obj)
+                return true;
+            if (obj == null)
+                return false;
+            if (getClass() != obj.getClass())
+                return false;
+            CommandKey other = (CommandKey) obj;
+            return Objects.equals(command, other.command) && Objects.equals(subCommand, other.subCommand);
+        }
+    }
+    
+    static class CommandWithProps {
+        private final Command cmd;
+        private final String summary;
+
+        static CommandWithProps of(Command cmd, Map<String, ?> props) {
+            return new CommandWithProps(cmd, (String) props.get(Command.PROPERTY_NAME_SUMMARY));
+        }
+        
+        CommandWithProps(Command cmd, String summary) {
+            this.cmd = cmd;
+            this.summary = summary;
+        }
+    }
+}
diff --git a/cli/src/main/java/org/apache/sling/cli/impl/ExecutionTrigger.java b/cli/src/main/java/org/apache/sling/cli/impl/ExecutionTrigger.java
new file mode 100644
index 0000000..23fa1b8
--- /dev/null
+++ b/cli/src/main/java/org/apache/sling/cli/impl/ExecutionTrigger.java
@@ -0,0 +1,37 @@
+/*
+ * 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;
+
+import org.osgi.framework.BundleContext;
+import org.osgi.framework.FrameworkEvent;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.Reference;
+
+@Component
+public class ExecutionTrigger {
+
+    @Reference
+    private CommandProcessor processor;
+
+    protected void activate(BundleContext ctx) {
+        ctx.addFrameworkListener(evt -> {
+            if (evt.getType() == FrameworkEvent.STARTED)
+                new Thread(() -> processor.runCommand(), getClass().getSimpleName() + "Thread").start();
+        });
+        // never removed but not important - it's one-shot anyway
+    }
+}
diff --git a/cli/src/main/java/org/apache/sling/cli/impl/jira/Version.java b/cli/src/main/java/org/apache/sling/cli/impl/jira/Version.java
new file mode 100644
index 0000000..7cce8d5
--- /dev/null
+++ b/cli/src/main/java/org/apache/sling/cli/impl/jira/Version.java
@@ -0,0 +1,47 @@
+/*
+ * 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.jira;
+
+public class Version {
+    private int id;
+    private String name;
+    private int issuesFixedCount;
+
+    public int getId() {
+        return id;
+    }
+
+    public void setId(int id) {
+        this.id = id;
+    }
+
+    public String getName() {
+        return name;
+    }
+
+    public void setName(String name) {
+        this.name = name;
+    }
+    
+    public int getIssuesFixedCount() {
+        return issuesFixedCount;
+    }
+    
+    public void setRelatedIssuesCount(int relatedIssuesCount) {
+        this.issuesFixedCount = relatedIssuesCount;
+    }
+}
diff --git a/cli/src/main/java/org/apache/sling/cli/impl/jira/VersionFinder.java b/cli/src/main/java/org/apache/sling/cli/impl/jira/VersionFinder.java
new file mode 100644
index 0000000..5bf0406
--- /dev/null
+++ b/cli/src/main/java/org/apache/sling/cli/impl/jira/VersionFinder.java
@@ -0,0 +1,98 @@
+/*
+ * 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.jira;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.lang.reflect.Type;
+import java.util.List;
+
+import org.apache.http.client.methods.CloseableHttpResponse;
+import org.apache.http.client.methods.HttpGet;
+import org.apache.http.impl.client.CloseableHttpClient;
+import org.apache.http.impl.client.HttpClients;
+import org.osgi.service.component.annotations.Component;
+
+import com.google.gson.Gson;
+import com.google.gson.reflect.TypeToken;
+
+@Component(service = VersionFinder.class)
+public class VersionFinder {
+
+    public Version find(String versionName) throws IOException {
+        Version version;
+        
+        try (CloseableHttpClient client = HttpClients.createDefault()) {
+            version = findVersion(versionName, client);
+            populateRelatedIssuesCount(client, version);
+        }
+        
+        return version;
+    }
+
+    private Version findVersion(String versionName, CloseableHttpClient client) throws IOException {
+        Version version;
+        HttpGet get = new HttpGet("https://issues.apache.org/jira/rest/api/2/project/SLING/versions");
+        get.addHeader("Accept", "application/json");
+        try (CloseableHttpResponse response = client.execute(get)) {
+            try (InputStream content = response.getEntity().getContent();
+                    InputStreamReader reader = new InputStreamReader(content)) {
+                if (response.getStatusLine().getStatusCode() != 200)
+                    throw new IOException("Status line : " + response.getStatusLine());
+                Gson gson = new Gson();
+                Type collectionType = TypeToken.getParameterized(List.class, Version.class).getType();
+                List<Version> versions = gson.fromJson(reader, collectionType);
+                version = versions.stream()
+                    .filter(v -> v.getName().equals(versionName))
+                    .findFirst()
+                    .orElseThrow( () -> new IllegalArgumentException("No version found with name " + versionName));
+            }
+        }
+        return version;
+    }
+
+    private void populateRelatedIssuesCount(CloseableHttpClient client, Version version) throws IOException {
+
+        HttpGet get = new HttpGet("https://issues.apache.org/jira/rest/api/2/version/" + version.getId() +"/relatedIssueCounts");
+        get.addHeader("Accept", "application/json");
+        try (CloseableHttpResponse response = client.execute(get)) {
+            try (InputStream content = response.getEntity().getContent();
+                    InputStreamReader reader = new InputStreamReader(content)) {
+                if (response.getStatusLine().getStatusCode() != 200)
+                    throw new IOException("Status line : " + response.getStatusLine());
+                Gson gson = new Gson();
+                VersionRelatedIssuesCount issuesCount = gson.fromJson(reader, VersionRelatedIssuesCount.class);
+                
+                version.setRelatedIssuesCount(issuesCount.getIssuesFixedCount());
+            }
+        }
+    }
+
+    static class VersionRelatedIssuesCount {
+
+        private int issuesFixedCount;
+
+        public int getIssuesFixedCount() {
+            return issuesFixedCount;
+        }
+
+        public void setIssuesFixedCount(int issuesFixedCount) {
+            this.issuesFixedCount = issuesFixedCount;
+        }
+    }
+}
diff --git a/cli/src/main/java/org/apache/sling/cli/impl/nexus/StagingRepositories.java b/cli/src/main/java/org/apache/sling/cli/impl/nexus/StagingRepositories.java
new file mode 100644
index 0000000..84e1a77
--- /dev/null
+++ b/cli/src/main/java/org/apache/sling/cli/impl/nexus/StagingRepositories.java
@@ -0,0 +1,33 @@
+/*
+ * 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.nexus;
+
+import java.util.List;
+
+public class StagingRepositories {
+
+    private List<StagingRepository> data;
+
+    public List<StagingRepository> getData() {
+        return data;
+    }
+
+    public void setData(List<StagingRepository> data) {
+        this.data = data;
+    }
+
+}
diff --git a/cli/src/main/java/org/apache/sling/cli/impl/nexus/StagingRepository.java b/cli/src/main/java/org/apache/sling/cli/impl/nexus/StagingRepository.java
new file mode 100644
index 0000000..167cebb
--- /dev/null
+++ b/cli/src/main/java/org/apache/sling/cli/impl/nexus/StagingRepository.java
@@ -0,0 +1,65 @@
+/*
+ * 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.nexus;
+
+/**
+ * DTO for GSON usage
+ *
+ */
+public class StagingRepository {
+    
+    enum Status {
+        open, closed;
+    }
+    
+    private String description;
+    private String repositoryId;
+    private String repositoryURI;
+    private Status type;
+
+    public String getDescription() {
+        return description;
+    }
+
+    public void setDescription(String description) {
+        this.description = description;
+    }
+
+    public String getRepositoryId() {
+        return repositoryId;
+    }
+
+    public void setRepositoryId(String repositoryId) {
+        this.repositoryId = repositoryId;
+    }
+
+    public String getRepositoryURI() {
+        return repositoryURI;
+    }
+
+    public void setRepositoryURI(String repositoryURI) {
+        this.repositoryURI = repositoryURI;
+    }
+    
+    public Status getType() {
+        return type;
+    }
+    
+    public void setType(Status type) {
+        this.type = type;
+    }
+}
diff --git a/cli/src/main/java/org/apache/sling/cli/impl/nexus/StagingRepositoryFinder.java b/cli/src/main/java/org/apache/sling/cli/impl/nexus/StagingRepositoryFinder.java
new file mode 100644
index 0000000..3ef7992
--- /dev/null
+++ b/cli/src/main/java/org/apache/sling/cli/impl/nexus/StagingRepositoryFinder.java
@@ -0,0 +1,86 @@
+/*
+ * 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.nexus;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+
+import org.apache.http.auth.AuthScope;
+import org.apache.http.auth.UsernamePasswordCredentials;
+import org.apache.http.client.methods.CloseableHttpResponse;
+import org.apache.http.client.methods.HttpGet;
+import org.apache.http.impl.client.BasicCredentialsProvider;
+import org.apache.http.impl.client.CloseableHttpClient;
+import org.apache.http.impl.client.HttpClients;
+import org.apache.sling.cli.impl.nexus.StagingRepository.Status;
+import org.osgi.service.component.annotations.Activate;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.ConfigurationPolicy;
+import org.osgi.service.metatype.annotations.AttributeDefinition;
+import org.osgi.service.metatype.annotations.Designate;
+import org.osgi.service.metatype.annotations.ObjectClassDefinition;
+
+import com.google.gson.Gson;
+
+@Component(
+    configurationPolicy = ConfigurationPolicy.REQUIRE,
+    service = StagingRepositoryFinder.class
+)
+@Designate(ocd = StagingRepositoryFinder.Config.class)
+public class StagingRepositoryFinder {
+
+    @ObjectClassDefinition
+    static @interface Config {
+        @AttributeDefinition(name="Username")
+        String username();
+        
+        @AttributeDefinition(name="Password")
+        String password();
+    }
+
+    private BasicCredentialsProvider credentialsProvider;
+    
+    @Activate
+    protected void activate(Config cfg) {
+        credentialsProvider = new BasicCredentialsProvider();
+        credentialsProvider.setCredentials(new AuthScope("repository.apache.org", 443), 
+                new UsernamePasswordCredentials(cfg.username(), cfg.password()));
+    }
+
+    public StagingRepository find(int stagingRepositoryId) throws IOException {
+        try ( CloseableHttpClient client = HttpClients.custom()
+                .setDefaultCredentialsProvider(credentialsProvider)
+                .build() ) {
+            HttpGet get = new HttpGet("https://repository.apache.org/service/local/staging/profile_repositories");
+            get.addHeader("Accept", "application/json");
+            try ( CloseableHttpResponse response = client.execute(get)) {
+                try ( InputStream content = response.getEntity().getContent();
+                        InputStreamReader reader = new InputStreamReader(content)) {
+                    if ( response.getStatusLine().getStatusCode() != 200 )
+                        throw new IOException("Status line : " + response.getStatusLine());
+                    Gson gson = new Gson();
+                    return gson.fromJson(reader, StagingRepositories.class).getData().stream()
+                        .filter( r -> r.getType() == Status.closed)
+                        .filter( r -> r.getRepositoryId().endsWith("-" + stagingRepositoryId))
+                        .findFirst()
+                        .orElseThrow(() -> new IllegalArgumentException("No repository found with id " + stagingRepositoryId));
+                }
+            }
+        }
+    }
+}
diff --git a/cli/src/main/java/org/apache/sling/cli/impl/release/PrepareVoteEmailCommand.java b/cli/src/main/java/org/apache/sling/cli/impl/release/PrepareVoteEmailCommand.java
new file mode 100644
index 0000000..eff3a3f
--- /dev/null
+++ b/cli/src/main/java/org/apache/sling/cli/impl/release/PrepareVoteEmailCommand.java
@@ -0,0 +1,98 @@
+/*
+ * 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 org.apache.sling.cli.impl.Command;
+import org.apache.sling.cli.impl.jira.Version;
+import org.apache.sling.cli.impl.jira.VersionFinder;
+import org.apache.sling.cli.impl.nexus.StagingRepository;
+import org.apache.sling.cli.impl.nexus.StagingRepositoryFinder;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.Reference;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@Component(service = Command.class, property = {
+    Command.PROPERTY_NAME_COMMAND + "=release",
+    Command.PROPERTY_NAME_SUBCOMMAND + "=prepare-email",
+    Command.PROPERTY_NAME_SUMMARY + "=Prepares an email vote for the specified release." })
+public class PrepareVoteEmailCommand implements Command {
+
+    // TODO - replace with file template
+    private static final String EMAIL_TEMPLATE ="To: \"Sling Developers List\" <de...@sling.apache.org>\n" + 
+            "Subject: [VOTE] Release Apache Sling ##RELEASE_NAME##\n" + 
+            "\n" + 
+            "Hi,\n" + 
+            "\n" + 
+            "We solved ##FIXED_ISSUES_COUNT## issues in this release:\n" + 
+            "https://issues.apache.org/jira/browse/SLING/fixforversion/##VERSION_ID##\n" + 
+            "\n" + 
+            "Staging repository:\n" + 
+            "https://repository.apache.org/content/repositories/orgapachesling-##RELEASE_ID##/\n" + 
+            "\n" + 
+            "You can use this UNIX script to download the release and verify the signatures:\n" + 
+            "https://gitbox.apache.org/repos/asf?p=sling-tooling-release.git;a=blob;f=check_staged_release.sh;hb=HEAD\n" + 
+            "\n" + 
+            "Usage:\n" + 
+            "sh check_staged_release.sh ##RELEASE_ID## /tmp/sling-staging\n" + 
+            "\n" + 
+            "Please vote to approve this release:\n" + 
+            "\n" + 
+            "  [ ] +1 Approve the release\n" + 
+            "  [ ]  0 Don't care\n" + 
+            "  [ ] -1 Don't release, because ...\n" + 
+            "\n" + 
+            "This majority vote is open for at least 72 hours.\n";
+
+    private final Logger logger = LoggerFactory.getLogger(getClass());
+    
+    @Reference
+    private StagingRepositoryFinder repoFinder;
+    
+    @Reference
+    private VersionFinder versionFinder;
+
+    @Override
+    public void execute(String target) {
+        try {
+            int repoId = Integer.parseInt(target);
+            StagingRepository repo = repoFinder.find(repoId);
+            String cleanVersion = getCleanVersion(repo.getDescription());
+            Version version = versionFinder.find(cleanVersion);
+            
+            String emailContents = EMAIL_TEMPLATE
+                    .replace("##RELEASE_NAME##", cleanVersion)
+                    .replace("##RELEASE_ID##", String.valueOf(repoId))
+                    .replace("##VERSION_ID##", String.valueOf(version.getId()))
+                    .replace("##FIXED_ISSUES_COUNT##", String.valueOf(version.getIssuesFixedCount()));
+                    
+            logger.info(emailContents);
+
+        } catch (IOException e) {
+            logger.warn("Failed executing command", e);
+        }
+    }
+
+    static String getCleanVersion(String repoDescription) {
+        return repoDescription
+                .replace("Apache Sling ", "") // Apache Sling prefix
+                .replaceAll(" RC[0-9]*$", ""); // 'release candidate' suffix 
+    }
+
+}
diff --git a/cli/src/main/java/org/apache/sling/cli/impl/release/TallyVotesCommand.java b/cli/src/main/java/org/apache/sling/cli/impl/release/TallyVotesCommand.java
new file mode 100644
index 0000000..690a4d2
--- /dev/null
+++ b/cli/src/main/java/org/apache/sling/cli/impl/release/TallyVotesCommand.java
@@ -0,0 +1,39 @@
+/*
+ * 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 org.apache.sling.cli.impl.Command;
+import org.osgi.service.component.annotations.Component;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@Component(service = Command.class, property = {
+    Command.PROPERTY_NAME_COMMAND+"=release",
+    Command.PROPERTY_NAME_SUBCOMMAND+"=tally-votes",
+    Command.PROPERTY_NAME_SUMMARY+"=Counts votes cast for a release and generates the result email"
+})
+public class TallyVotesCommand implements Command {
+
+    private final Logger logger = LoggerFactory.getLogger(getClass());
+
+    @Override
+    public void execute(String target) {
+        logger.info("Tallying votes for release {}", target);
+
+    }
+
+}
diff --git a/cli/src/main/resources/conf/logback-default.xml b/cli/src/main/resources/conf/logback-default.xml
new file mode 100644
index 0000000..8f2963f
--- /dev/null
+++ b/cli/src/main/resources/conf/logback-default.xml
@@ -0,0 +1,23 @@
+<!-- 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. -->
+<configuration>
+    <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
+        <encoder>
+            <pattern>%msg%n</pattern>
+        </encoder>
+    </appender>
+
+    <logger name="org.apache.sling.cli" level="INFO" />
+
+    <root level="WARN">
+        <appender-ref ref="STDOUT" />
+    </root>
+</configuration>
\ No newline at end of file
diff --git a/cli/src/main/resources/scripts/launcher.sh b/cli/src/main/resources/scripts/launcher.sh
new file mode 100755
index 0000000..6f0bcfb
--- /dev/null
+++ b/cli/src/main/resources/scripts/launcher.sh
@@ -0,0 +1,29 @@
+#!/bin/sh
+# ----------------------------------------------------------------------------------------
+# 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.
+# ----------------------------------------------------------------------------------------
+
+# TODO - contribute '-q' flag to launcher OR allow passthrough of org.slf4j.simpleLogger system properties
+
+
+# funky syntax needed to properly preserve arguments with whitespace
+ARGS_PROP="exec.args=$@"
+
+# Use exec to become pid 1, see https://docs.docker.com/develop/develop-images/dockerfile_best-practices/
+exec /usr/bin/java \
+	 -Dorg.slf4j.simpleLogger.logFile=/dev/null \
+	 -Dlogback.configurationFile=file:/usr/share/sling-cli/conf/logback-default.xml \
+	 -jar /usr/share/sling-cli/launcher/org.apache.sling.feature.launcher.jar \
+	 -f /usr/share/sling-cli/sling-cli.feature \
+	 -c /usr/share/sling-cli/artifacts \
+	 -D "$ARGS_PROP" \
+	 -V "asf.username=${ASF_USERNAME}" \
+	 -V "asf.password=${ASF_PASSWORD}"
\ No newline at end of file
diff --git a/cli/src/test/java/org/apache/sling/cli/impl/release/PrepareVoteEmailCommandTest.java b/cli/src/test/java/org/apache/sling/cli/impl/release/PrepareVoteEmailCommandTest.java
new file mode 100644
index 0000000..8dd81aa
--- /dev/null
+++ b/cli/src/test/java/org/apache/sling/cli/impl/release/PrepareVoteEmailCommandTest.java
@@ -0,0 +1,31 @@
+/*
+ * 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 static org.junit.Assert.assertEquals;
+
+import org.junit.Test;
+
+public class PrepareVoteEmailCommandTest {
+
+    @Test
+    public void cleanVersion() {
+        
+        assertEquals("Resource Merger 1.3.10", 
+                PrepareVoteEmailCommand.getCleanVersion("Apache Sling Resource Merger 1.3.10 RC1"));
+    }
+}