diff --git a/CHANGES.md b/CHANGES.md
index 72a0d432d..b434f7ed2 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -1,6 +1,8 @@
# Goomph releases
## [Unreleased]
+### Added
+- Added `CategoryPublisher` ([#124](https://github.com/diffplug/goomph/issues/124))
## [3.23.0] - 2020-06-17
### Added
diff --git a/README.md b/README.md
index 807816d74..fcafad55d 100644
--- a/README.md
+++ b/README.md
@@ -89,6 +89,7 @@ Below is an index of Goomph's capabilities, along with links to the javadoc wher
* [`asmaven`](https://javadoc.io/static/com.diffplug.gradle/goomph/3.23.0/com/diffplug/gradle/p2/AsMavenPlugin.html) downloads dependencies from a p2 repository and makes them available in a local maven repository.
* [`P2Model`](https://javadoc.io/static/com.diffplug.gradle/goomph/3.23.0/com/diffplug/gradle/p2/P2Model.html) models a set of p2 repositories and IUs, and provides convenience methods for running p2-director or the p2.mirror ant task against these.
* [`P2AntRunner`](https://javadoc.io/static/com.diffplug.gradle/goomph/3.23.0/com/diffplug/gradle/p2/P2AntRunner.html) runs eclipse ant tasks.
+* [`CategoryPublisher`](https://javadoc.io/static/com.diffplug.gradle/goomph/3.23.0/com/diffplug/gradle/p2/CategoryPublisher.html) models the CategoryPublisher eclipse application.
* [`FeaturesAndBundlesPublisher`](https://javadoc.io/static/com.diffplug.gradle/goomph/3.23.0/com/diffplug/gradle/p2/FeaturesAndBundlesPublisher.html) models the FeaturesAndBundlesPublisher eclipse application.
* [`Repo2Runnable`](https://javadoc.io/static/com.diffplug.gradle/goomph/3.23.0/com/diffplug/gradle/p2/Repo2Runnable.html) models the Repo2Runnable eclipse application.
diff --git a/src/main/java/com/diffplug/gradle/p2/CategoryPublisher.java b/src/main/java/com/diffplug/gradle/p2/CategoryPublisher.java
new file mode 100644
index 000000000..d7a268e73
--- /dev/null
+++ b/src/main/java/com/diffplug/gradle/p2/CategoryPublisher.java
@@ -0,0 +1,71 @@
+/*
+ * Copyright (C) 2020 DiffPlug
+ *
+ * Licensed 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
+ *
+ * https://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 com.diffplug.gradle.p2;
+
+
+import com.diffplug.gradle.FileMisc;
+import com.diffplug.gradle.eclipserunner.EclipseApp;
+import com.diffplug.gradle.pde.EclipseRelease;
+import com.diffplug.gradle.pde.PdeInstallation;
+import java.io.File;
+
+/**
+ * Models the CategoryPublisher application ([eclipse docs](https://wiki.eclipse.org/Equinox/p2/Publisher#Category_Publisher).
+ */
+public class CategoryPublisher extends EclipseApp {
+
+ private final EclipseRelease eclipseRelease;
+
+ /**
+ * Creates a CategoryPublisher
+ *
+ * @param eclipseRelease The eclipse release to be used to run the public application
+ * */
+ public CategoryPublisher(EclipseRelease eclipseRelease) {
+ super("org.eclipse.equinox.p2.publisher.CategoryPublisher");
+ consolelog();
+ this.eclipseRelease = eclipseRelease;
+ }
+
+ /** Compress the output index */
+ public void compress() {
+ addArg("compress");
+ }
+
+ /** Sets the given location to be the target for metadata. */
+ public void metadataRepository(File file) {
+ addArg("metadataRepository", FileMisc.asUrl(file));
+ }
+
+ /** Sets the given location of context metadata. */
+ public void contextMetadata(File file) {
+ addArg("contextMetadata", FileMisc.asUrl(file));
+ }
+
+ /** Sets the given location of the category definition. */
+ public void categoryDefinition(File file) {
+ addArg("categoryDefinition", FileMisc.asUrl(file));
+ }
+
+ /** Sets the given category qualifier */
+ public void categoryQualifier(String categoryQualifier) {
+ addArg("categoryQualifier", categoryQualifier);
+ }
+
+ public void runUsingPdeInstallation() throws Exception {
+ runUsing(PdeInstallation.from(eclipseRelease));
+ }
+}
diff --git a/src/test/java/com/diffplug/gradle/p2/CategoryPublisherTest.java b/src/test/java/com/diffplug/gradle/p2/CategoryPublisherTest.java
new file mode 100644
index 000000000..66d8a7604
--- /dev/null
+++ b/src/test/java/com/diffplug/gradle/p2/CategoryPublisherTest.java
@@ -0,0 +1,160 @@
+/*
+ * Copyright (C) 2020 DiffPlug
+ *
+ * Licensed 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
+ *
+ * https://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 com.diffplug.gradle.p2;
+
+
+import com.diffplug.gradle.GradleIntegrationTest;
+import java.io.File;
+import java.io.IOException;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import org.junit.Assert;
+import org.junit.Test;
+
+public class CategoryPublisherTest extends GradleIntegrationTest {
+
+ // Eclipse version used for application execution
+ private static String ECLIPSE_PDE_VERSION = "4.8.0";
+
+ private static String FEATURE_ID = "goomph.test.feature";
+ private static String FEATURE_VERSION = "1.1.1";
+ private static String FEATURE_JAR_NAME = FEATURE_ID + "_" + FEATURE_VERSION + ".jar";
+
+ private static String PLUGIN_NAME = "goomph.test.plugin";
+ private static String PLUGIN_VERSION = "0.2.0";
+ private static String PLUGIN_JAR_NAME = PLUGIN_NAME + "_" + PLUGIN_VERSION + ".jar";
+
+ // Directory used as target of the applications
+ private static String PROJECT_DIR_PATH = "project";
+ private static String PLUGINS_DIR_PATH = PROJECT_DIR_PATH + "/plugins";
+ private static String FEATURES_DIR_PATH = PROJECT_DIR_PATH + "/features";
+
+ private static String CATEGORY_FILE_PATH = "category/category.xml";
+ private static String CATEGORY_NAME = "TestCategory";
+
+ private static String PUBLISH_CATEGORY_TASK_NAME = "publishCategory";
+ private static String PUBLISH_FEATURES_AND_BUNDLES_TASK_NAME = "publishFeaturesAndBundles";
+
+ /**
+ * Tests the update site creation using the {@see FeaturesAndBundlesPublisher}
+ * and {@see CategoryPublisher}
+ **/
+ @Test
+ public void testCreateUpdateSite() throws IOException {
+
+ write(
+ "build.gradle",
+ "plugins {",
+ " id 'com.diffplug.p2.asmaven'",
+ "}",
+ "import com.diffplug.gradle.pde.EclipseRelease",
+ "import com.diffplug.gradle.p2.CategoryPublisher",
+ "import com.diffplug.gradle.p2.FeaturesAndBundlesPublisher",
+ "tasks.register('testProjectJar', Jar) {",
+ " archiveFileName = 'test.jar'",
+ " destinationDirectory = file('" + PLUGINS_DIR_PATH + "')",
+ " manifest{attributes('Bundle-SymbolicName': '" + PLUGIN_NAME + "', 'Bundle-Version': '" + PLUGIN_VERSION + "')}",
+ "}",
+ "tasks.register('" + PUBLISH_FEATURES_AND_BUNDLES_TASK_NAME + "') {",
+ " dependsOn('testProjectJar')",
+ " doLast {",
+ " new FeaturesAndBundlesPublisher().with {",
+ " source(file('" + PROJECT_DIR_PATH + "'))",
+ " inplace()",
+ " append()",
+ " publishArtifacts()",
+ " runUsingBootstrapper()",
+ " }",
+ " }",
+ "}",
+ "tasks.register('" + PUBLISH_CATEGORY_TASK_NAME + "') {",
+ " doLast {",
+ " new CategoryPublisher(EclipseRelease.official('" + ECLIPSE_PDE_VERSION + "')).with {",
+ " metadataRepository(file('" + PROJECT_DIR_PATH + "'))",
+ " categoryDefinition(file('" + CATEGORY_FILE_PATH + "'))",
+ " runUsingPdeInstallation()",
+ " }",
+ " }",
+ "}");
+
+ folder.newFolder(PLUGINS_DIR_PATH);
+ folder.newFolder(FEATURES_DIR_PATH);
+
+ writeFeatureXml();
+ writeCategoryDefinition();
+
+ /* Execute FeaturesAndBundlesPublisher using the file structure:
+ * project
+ * - features
+ * - feature.xml
+ * - plugins
+ * - test.jar // created by task 'testProjectJar'
+ */
+ gradleRunner().forwardOutput().withArguments(PUBLISH_FEATURES_AND_BUNDLES_TASK_NAME).build();
+
+ // Verify result of FeaturesAndBundlesPublisher application execution
+ String artifactsXml = read(PROJECT_DIR_PATH + "/artifacts.xml");
+ Assert.assertTrue("FeaturesAndBundles application does not found plugin specified in features.xml",
+ artifactsXml.contains("id='goomph.test.plugin'"));
+ Assert.assertTrue("FeaturesAndBundles application does not create a feature jar",
+ new File(folder.getRoot(), FEATURES_DIR_PATH + '/' + FEATURE_JAR_NAME).exists());
+ Assert.assertTrue("FeaturesAndBundles application does not create a plugin jar",
+ new File(folder.getRoot(), PLUGINS_DIR_PATH + '/' + PLUGIN_JAR_NAME).exists());
+
+ Pattern categoryContextPattern = Pattern.compile("\\s*" +
+ "");
+
+ String contentXML = read(PROJECT_DIR_PATH + "/content.xml");
+ Matcher m = categoryContextPattern.matcher(contentXML);
+ Assert.assertFalse("content.xml already contains category metadata", m.find());
+
+ // Execute CategoryPublisher
+ gradleRunner().forwardOutput().withArguments(PUBLISH_CATEGORY_TASK_NAME).build();
+
+ // Verify result of CategoryPublisher application execution
+ contentXML = read(PROJECT_DIR_PATH + "/content.xml");
+ m = categoryContextPattern.matcher(contentXML);
+ Assert.assertTrue("CategoryPublisher application does not add the category metadata to content.xml", m.find());
+ }
+
+ private void writeFeatureXml() throws IOException {
+ write(FEATURES_DIR_PATH + "/feature.xml",
+ "",
+ "",
+ " ",
+ " ",
+ "");
+ }
+
+ private void writeCategoryDefinition() throws IOException {
+ write(CATEGORY_FILE_PATH,
+ "",
+ "",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ "");
+ }
+}