You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@logging.apache.org by vy...@apache.org on 2022/12/19 10:21:07 UTC

[logging-log4j2] 01/33: LOG4J2-3628 Initial changelog import & export utilities.

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

vy pushed a commit to branch mvn-site-simplified
in repository https://gitbox.apache.org/repos/asf/logging-log4j2.git

commit 68a2b33b7afb6d22e98f500f57cd45695960794d
Author: Volkan Yazıcı <vo...@yazi.ci>
AuthorDate: Mon Nov 21 22:06:39 2022 +0100

    LOG4J2-3628 Initial changelog import & export utilities.
---
 log4j-internal-util/pom.xml                        |  34 ++
 .../internal/util/PositionalSaxEventHandler.java   | 103 ++++++
 .../logging/log4j/internal/util/StringUtils.java   |  31 ++
 .../logging/log4j/internal/util/XmlReader.java     |  85 +++++
 .../logging/log4j/internal/util/XmlWriter.java     | 109 ++++++
 .../internal/util/changelog/ChangelogEntry.java    | 213 +++++++++++
 .../internal/util/changelog/ChangelogFiles.java    |  37 ++
 .../internal/util/changelog/ChangelogRelease.java  |  69 ++++
 .../util/changelog/exporter/AsciiDocExporter.java  | 394 +++++++++++++++++++++
 .../changelog/exporter/AsciiDocExporterArgs.java   |  39 ++
 .../util/changelog/importer/MavenChanges.java      | 210 +++++++++++
 .../changelog/importer/MavenChangesImporter.java   | 135 +++++++
 .../importer/MavenChangesImporterArgs.java         |  39 ++
 pom.xml                                            |   1 +
 14 files changed, 1499 insertions(+)

diff --git a/log4j-internal-util/pom.xml b/log4j-internal-util/pom.xml
new file mode 100644
index 0000000000..b1af77b4c6
--- /dev/null
+++ b/log4j-internal-util/pom.xml
@@ -0,0 +1,34 @@
+<?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">
+
+  <parent>
+    <artifactId>log4j</artifactId>
+    <groupId>org.apache.logging.log4j</groupId>
+    <version>2.19.1-SNAPSHOT</version>
+  </parent>
+
+  <modelVersion>4.0.0</modelVersion>
+
+  <artifactId>log4j-internal-util</artifactId>
+  <name>Apache Log4j internal utilities</name>
+  <description>Internal Log4j utilities for project's build infrastructure.</description>
+
+</project>
diff --git a/log4j-internal-util/src/main/java/org/apache/logging/log4j/internal/util/PositionalSaxEventHandler.java b/log4j-internal-util/src/main/java/org/apache/logging/log4j/internal/util/PositionalSaxEventHandler.java
new file mode 100644
index 0000000000..b74f75018e
--- /dev/null
+++ b/log4j-internal-util/src/main/java/org/apache/logging/log4j/internal/util/PositionalSaxEventHandler.java
@@ -0,0 +1,103 @@
+/*
+ * 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.logging.log4j.internal.util;
+
+import org.w3c.dom.Document;
+import org.w3c.dom.Element;
+import org.w3c.dom.Node;
+import org.xml.sax.Attributes;
+import org.xml.sax.Locator;
+import org.xml.sax.helpers.DefaultHandler;
+
+import java.util.Stack;
+
+/**
+ * A SAX2 event handler adding the associated line number to each emitted nodes' user data.
+ * <p>
+ * The added node user data is keyed with {@code lineNumber}.
+ * </p>
+ */
+final class PositionalSaxEventHandler extends DefaultHandler {
+
+    private final Stack<Element> elementStack = new Stack<>();
+
+    private final StringBuilder textBuffer = new StringBuilder();
+
+    private final Document document;
+
+    private Locator locator;
+
+    PositionalSaxEventHandler(final Document document) {
+        this.document = document;
+    }
+
+    @Override
+    public void setDocumentLocator(final Locator locator) {
+        this.locator = locator;
+    }
+
+    @Override
+    public void startElement(
+            final String uri,
+            final String localName,
+            final String qName,
+            final Attributes attributes) {
+        addTextIfNeeded();
+        final Element element = document.createElement(qName);
+        for (int attributeIndex = 0; attributeIndex < attributes.getLength(); attributeIndex++) {
+            final String attributeQName = attributes.getQName(attributeIndex);
+            final String attributeValue = attributes.getValue(attributeIndex);
+            element.setAttribute(attributeQName, attributeValue);
+        }
+        element.setUserData("lineNumber", String.valueOf(locator.getLineNumber()), null);
+        elementStack.push(element);
+    }
+
+    @Override
+    public void endElement(
+            final String uri,
+            final String localName,
+            final String qName) {
+        addTextIfNeeded();
+        final Element closedElement = elementStack.pop();
+        final boolean rootElement = elementStack.isEmpty();
+        if (rootElement) {
+            document.appendChild(closedElement);
+        } else {
+            final Element parentElement = elementStack.peek();
+            parentElement.appendChild(closedElement);
+        }
+    }
+
+    @Override
+    public void characters(final char[] buffer, final int start, final int length) {
+        textBuffer.append(buffer, start, length);
+    }
+
+    /**
+     * Outputs text accumulated under the current node.
+     */
+    private void addTextIfNeeded() {
+        if (textBuffer.length() > 0) {
+            final Element element = elementStack.peek();
+            final Node textNode = document.createTextNode(textBuffer.toString());
+            element.appendChild(textNode);
+            textBuffer.delete(0, textBuffer.length());
+        }
+    }
+
+}
diff --git a/log4j-internal-util/src/main/java/org/apache/logging/log4j/internal/util/StringUtils.java b/log4j-internal-util/src/main/java/org/apache/logging/log4j/internal/util/StringUtils.java
new file mode 100644
index 0000000000..93bc1c942a
--- /dev/null
+++ b/log4j-internal-util/src/main/java/org/apache/logging/log4j/internal/util/StringUtils.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.logging.log4j.internal.util;
+
+public final class StringUtils {
+
+    private StringUtils() {}
+
+    public static String trimNullable(final String input) {
+        return input != null ? input.trim() : null;
+    }
+
+    public static boolean isBlank(final String input) {
+        return input == null || input.matches("\\s*");
+    }
+
+}
diff --git a/log4j-internal-util/src/main/java/org/apache/logging/log4j/internal/util/XmlReader.java b/log4j-internal-util/src/main/java/org/apache/logging/log4j/internal/util/XmlReader.java
new file mode 100644
index 0000000000..f4045315d7
--- /dev/null
+++ b/log4j-internal-util/src/main/java/org/apache/logging/log4j/internal/util/XmlReader.java
@@ -0,0 +1,85 @@
+/*
+ * 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.logging.log4j.internal.util;
+
+import org.w3c.dom.Document;
+import org.w3c.dom.Element;
+import org.w3c.dom.Node;
+
+import javax.xml.parsers.DocumentBuilder;
+import javax.xml.parsers.DocumentBuilderFactory;
+import javax.xml.parsers.SAXParser;
+import javax.xml.parsers.SAXParserFactory;
+import java.io.FileInputStream;
+import java.io.InputStream;
+import java.nio.file.Path;
+
+/**
+ * A SAX2-based XML reader.
+ */
+public final class XmlReader {
+
+    private XmlReader() {}
+
+    public static Element readXmlFileRootElement(final Path path, final String rootElementName) {
+        try (final InputStream inputStream = new FileInputStream(path.toFile())) {
+            final Document document = readXml(inputStream);
+            final Element rootElement = document.getDocumentElement();
+            if (!rootElementName.equals(rootElement.getNodeName())) {
+                final String message = String.format(
+                        "was expecting root element to be called `%s`, found: `%s`",
+                        rootElementName, rootElement.getNodeName());
+                throw new IllegalArgumentException(message);
+            }
+            return rootElement;
+        } catch (final Exception error) {
+            final String message = String.format(
+                    "XML read failure for file `%s` and root element `%s`", path, rootElementName);
+            throw new RuntimeException(message, error);
+        }
+    }
+
+    private static Document readXml(final InputStream inputStream) throws Exception {
+        final SAXParserFactory parserFactory = SAXParserFactory.newInstance();
+        final SAXParser parser = parserFactory.newSAXParser();
+        final DocumentBuilderFactory documentBuilderFactory = DocumentBuilderFactory.newInstance();
+        final DocumentBuilder documentBuilder = documentBuilderFactory.newDocumentBuilder();
+        final Document document = documentBuilder.newDocument();
+        PositionalSaxEventHandler handler = new PositionalSaxEventHandler(document);
+        parser.parse(inputStream, handler);
+        return document;
+    }
+
+    public static RuntimeException failureAtXmlNode(
+            final Node node,
+            final String messageFormat,
+            final Object... messageArgs) {
+        return failureAtXmlNode(null, node, messageFormat, messageArgs);
+    }
+
+    public static RuntimeException failureAtXmlNode(
+            final Throwable cause,
+            final Node node,
+            final String messageFormat,
+            final Object... messageArgs) {
+        final Object lineNumber = node.getUserData("lineNumber");
+        final String messagePrefix = String.format("[line %s] ", lineNumber);
+        final String message = String.format(messagePrefix + messageFormat, messageArgs);
+        return new IllegalArgumentException(message, cause);
+    }
+
+}
diff --git a/log4j-internal-util/src/main/java/org/apache/logging/log4j/internal/util/XmlWriter.java b/log4j-internal-util/src/main/java/org/apache/logging/log4j/internal/util/XmlWriter.java
new file mode 100644
index 0000000000..b797eb0853
--- /dev/null
+++ b/log4j-internal-util/src/main/java/org/apache/logging/log4j/internal/util/XmlWriter.java
@@ -0,0 +1,109 @@
+/*
+ * 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.logging.log4j.internal.util;
+
+import org.w3c.dom.Comment;
+import org.w3c.dom.Document;
+
+import javax.xml.parsers.DocumentBuilder;
+import javax.xml.parsers.DocumentBuilderFactory;
+import javax.xml.transform.OutputKeys;
+import javax.xml.transform.Transformer;
+import javax.xml.transform.TransformerFactory;
+import javax.xml.transform.dom.DOMSource;
+import javax.xml.transform.stream.StreamResult;
+import java.io.StringWriter;
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.OpenOption;
+import java.nio.file.Path;
+import java.nio.file.StandardOpenOption;
+import java.util.function.Consumer;
+
+public final class XmlWriter {
+
+    private static final Charset ENCODING = StandardCharsets.UTF_8;
+
+    private XmlWriter() {}
+
+    public static void toFile(final Path filepath, final Consumer<Document> documentConsumer) {
+        try {
+            final String xml = toString(documentConsumer);
+            final byte[] xmlBytes = xml.getBytes(ENCODING);
+            Files.createDirectories(filepath.getParent());
+            Files.write(filepath, xmlBytes, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
+        } catch (final Exception error) {
+            final String message = String.format("failed writing XML to file `%s`", filepath);
+            throw new RuntimeException(message, error);
+        }
+    }
+
+    public static String toString(final Consumer<Document> documentConsumer) {
+        try {
+
+            // Create the document.
+            final DocumentBuilderFactory documentBuilderFactory = DocumentBuilderFactory.newInstance();
+            final DocumentBuilder documentBuilder = documentBuilderFactory.newDocumentBuilder();
+
+            // Append the license comment.
+            final Document document = documentBuilder.newDocument();
+            document.setXmlStandalone(true);
+            final Comment licenseComment = document.createComment("\n" +
+                    "   Licensed to the Apache Software Foundation (ASF) under one or more\n" +
+                    "   contributor license agreements.  See the NOTICE file distributed with\n" +
+                    "   this work for additional information regarding copyright ownership.\n" +
+                    "   The ASF licenses this file to You under the Apache License, Version 2.0\n" +
+                    "   (the \"License\"); you may not use this file except in compliance with\n" +
+                    "   the License.  You may obtain a copy of the License at\n" +
+                    "\n" +
+                    "       http://www.apache.org/licenses/LICENSE-2.0\n" +
+                    "\n" +
+                    "   Unless required by applicable law or agreed to in writing, software\n" +
+                    "   distributed under the License is distributed on an \"AS IS\" BASIS,\n" +
+                    "   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n" +
+                    "   See the License for the specific language governing permissions and\n" +
+                    "   limitations under the License." +
+                    "\n");
+            document.appendChild(licenseComment);
+
+            // Execute request changes.
+            documentConsumer.accept(document);
+
+            // Serialize the document.
+            return serializeXmlDocument(document);
+
+        } catch (final Exception error) {
+            throw new RuntimeException("failed writing XML", error);
+        }
+    }
+
+    private static String serializeXmlDocument(final Document document) throws Exception {
+        final Transformer transformer = TransformerFactory.newInstance().newTransformer();
+        final StreamResult result = new StreamResult(new StringWriter());
+        final DOMSource source = new DOMSource(document);
+        transformer.setOutputProperty(OutputKeys.ENCODING, ENCODING.name());
+        transformer.setOutputProperty(OutputKeys.INDENT, "yes");
+        transformer.setOutputProperty("{http://xml.apache.org/xslt}indent-amount", "2");
+        transformer.transform(source, result);
+        return result.getWriter().toString()
+                // Life is too short to solve DOM transformer issues decently.
+                .replace("?><!--", "?>\n<!--")
+                .replace("--><", "-->\n<");
+    }
+
+}
diff --git a/log4j-internal-util/src/main/java/org/apache/logging/log4j/internal/util/changelog/ChangelogEntry.java b/log4j-internal-util/src/main/java/org/apache/logging/log4j/internal/util/changelog/ChangelogEntry.java
new file mode 100644
index 0000000000..f662a75c9b
--- /dev/null
+++ b/log4j-internal-util/src/main/java/org/apache/logging/log4j/internal/util/changelog/ChangelogEntry.java
@@ -0,0 +1,213 @@
+/*
+ * 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.logging.log4j.internal.util.changelog;
+
+import org.apache.logging.log4j.internal.util.XmlReader;
+import org.apache.logging.log4j.internal.util.XmlWriter;
+import org.w3c.dom.Element;
+import org.w3c.dom.NodeList;
+
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Locale;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+
+import static org.apache.logging.log4j.internal.util.StringUtils.trimNullable;
+
+public final class ChangelogEntry {
+
+    public final Type type;
+
+    public final List<Issue> issues;
+
+    public final List<Author> authors;
+
+    public final Description description;
+
+    public enum Type {
+
+        ADDED,
+
+        CHANGED,
+
+        DEPRECATED,
+
+        REMOVED,
+
+        FIXED,
+
+        SECURITY;
+
+        private String toXmlAttribute() {
+            return toString().toLowerCase(Locale.US);
+        }
+
+        private static Type fromXmlAttribute(final String attribute) {
+            final String upperCaseAttribute = attribute != null ? attribute.toUpperCase(Locale.US) : null;
+            return Type.valueOf(upperCaseAttribute);
+        }
+
+    }
+
+    public static final class Issue {
+
+        public final String id;
+
+        public final String link;
+
+        public Issue(final String id, final String link) {
+            this.id = id;
+            this.link = link;
+        }
+
+    }
+
+    public static final class Author {
+
+        public final String id;
+
+        public final String name;
+
+        public Author(final String id, final String name) {
+            this.id = id;
+            this.name = name;
+        }
+
+    }
+
+    public static final class Description {
+
+        public final String format;
+
+        public final String text;
+
+        public Description(final String format, final String text) {
+            this.format = format;
+            this.text = text;
+        }
+
+    }
+
+    public ChangelogEntry(
+            final Type type,
+            final List<Issue> issues,
+            final List<Author> authors,
+            final Description description) {
+        this.type = type;
+        this.issues = issues;
+        this.authors = authors;
+        this.description = description;
+    }
+
+    public void writeToXmlFile(final Path path) {
+        XmlWriter.toFile(path, document -> {
+
+            // Create the `entry` root element.
+            final Element entryElement = document.createElement("entry");
+            entryElement.setAttribute("type", type.toXmlAttribute());
+            document.appendChild(entryElement);
+
+            // Create the `issue` elements.
+            issues.forEach(issue -> {
+                final Element issueElement = document.createElement("issue");
+                issueElement.setAttribute("id", issue.id);
+                issueElement.setAttribute("link", issue.link);
+                entryElement.appendChild(issueElement);
+            });
+
+            // Create the `author` elements.
+            authors.forEach(author -> {
+                final Element authorElement = document.createElement("author");
+                if (author.id != null) {
+                    authorElement.setAttribute("id", author.id);
+                } else {
+                    authorElement.setAttribute("name", author.name);
+                }
+                entryElement.appendChild(authorElement);
+            });
+
+            // Create the `description` element.
+            final Element descriptionElement = document.createElement("description");
+            if (description.format != null) {
+                descriptionElement.setAttribute("format", description.format);
+            }
+            descriptionElement.setTextContent(description.text);
+            entryElement.appendChild(descriptionElement);
+
+        });
+    }
+
+    public static ChangelogEntry readFromXmlFile(final Path path) {
+
+        // Read the `entry` root element.
+        final Element entryElement = XmlReader.readXmlFileRootElement(path, "entry");
+        final String typeAttribute = entryElement.getAttribute("type");
+        final Type type;
+        try {
+            type = Type.fromXmlAttribute(typeAttribute);
+        } catch (final Exception error) {
+            throw XmlReader.failureAtXmlNode(error, entryElement, "`type` attribute read failure");
+        }
+
+        // Read the `issue` elements.
+        final NodeList issueElements = entryElement.getElementsByTagName("issue");
+        final int issueCount = issueElements.getLength();
+        final List<Issue> issues = IntStream
+                .range(0, issueCount)
+                .mapToObj(issueIndex -> {
+                    final Element issueElement = (Element) issueElements.item(issueIndex);
+                    final String issueId = issueElement.getAttribute("id");
+                    final String issueLink = issueElement.getAttribute("link");
+                    return new Issue(issueId, issueLink);
+                })
+                .collect(Collectors.toList());
+
+        // Read the `author` elements.
+        final NodeList authorElements = entryElement.getElementsByTagName("author");
+        final int authorCount = authorElements.getLength();
+        if (authorCount < 1) {
+            throw XmlReader.failureAtXmlNode(entryElement, "no `author` elements found");
+        }
+        final List<Author> authors = IntStream
+                .range(0, authorCount)
+                .mapToObj(authorIndex -> {
+                    final Element authorElement = (Element) authorElements.item(authorIndex);
+                    final String authorId = authorElement.getAttribute("id");
+                    final String authorName = authorElement.getAttribute("name");
+                    return new Author(authorId, authorName);
+                })
+                .collect(Collectors.toList());
+
+        // Read the `description` element.
+        final NodeList descriptionElements = entryElement.getElementsByTagName("description");
+        final int descriptionCount = descriptionElements.getLength();
+        if (descriptionCount != 1) {
+            throw XmlReader.failureAtXmlNode(
+                    entryElement, "was expecting a single `description` element, found: %d", descriptionCount);
+        }
+        final Element descriptionElement = (Element) descriptionElements.item(0);
+        final String descriptionFormat = descriptionElement.getAttribute("format");
+        final String descriptionText = trimNullable(descriptionElement.getTextContent());
+        final Description description = new Description(descriptionFormat, descriptionText);
+
+        // Create the instance.
+        return new ChangelogEntry(type, issues, authors, description);
+
+    }
+
+}
diff --git a/log4j-internal-util/src/main/java/org/apache/logging/log4j/internal/util/changelog/ChangelogFiles.java b/log4j-internal-util/src/main/java/org/apache/logging/log4j/internal/util/changelog/ChangelogFiles.java
new file mode 100644
index 0000000000..598c6e378a
--- /dev/null
+++ b/log4j-internal-util/src/main/java/org/apache/logging/log4j/internal/util/changelog/ChangelogFiles.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.logging.log4j.internal.util.changelog;
+
+import java.nio.file.Path;
+
+public final class ChangelogFiles {
+
+    private ChangelogFiles() {}
+
+    public static Path changelogDirectory(final Path projectRootDirectory) {
+        return projectRootDirectory.resolve("changelog");
+    }
+
+    public static Path releaseXmlFile(final Path releaseDirectory) {
+        return releaseDirectory.resolve(".release.xml");
+    }
+
+    public static Path introAsciiDocFile(final Path releaseDirectory) {
+        return releaseDirectory.resolve(".intro.adoc");
+    }
+
+}
diff --git a/log4j-internal-util/src/main/java/org/apache/logging/log4j/internal/util/changelog/ChangelogRelease.java b/log4j-internal-util/src/main/java/org/apache/logging/log4j/internal/util/changelog/ChangelogRelease.java
new file mode 100644
index 0000000000..d044f42eca
--- /dev/null
+++ b/log4j-internal-util/src/main/java/org/apache/logging/log4j/internal/util/changelog/ChangelogRelease.java
@@ -0,0 +1,69 @@
+/*
+ * 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.logging.log4j.internal.util.changelog;
+
+import org.apache.logging.log4j.internal.util.XmlReader;
+import org.apache.logging.log4j.internal.util.XmlWriter;
+import org.w3c.dom.Element;
+
+import java.nio.file.Path;
+
+import static org.apache.logging.log4j.internal.util.StringUtils.trimNullable;
+
+public final class ChangelogRelease {
+
+    public final String version;
+
+    public final String date;
+
+    public ChangelogRelease(final String version, final String date) {
+        this.version = version;
+        this.date = date;
+    }
+
+    public void writeToXmlFile(final Path path) {
+        XmlWriter.toFile(path, document -> {
+            final Element releaseElement = document.createElement("release");
+            releaseElement.setAttribute("version", version);
+            releaseElement.setAttribute("date", date);
+            document.appendChild(releaseElement);
+        });
+    }
+
+    public static ChangelogRelease readFromXmlFile(final Path path) {
+
+        // Read the XML file.
+        final Element releaseElement = XmlReader.readXmlFileRootElement(path, "release");
+
+        // Read the `version` attribute.
+        final String version = trimNullable(releaseElement.getAttribute("version"));
+        if (version == null) {
+            throw new IllegalArgumentException("blank or missing attribute: `version`");
+        }
+
+        // Read the `date` attribute.
+        final String date = trimNullable(releaseElement.getAttribute("date"));
+        if (date == null) {
+            throw new IllegalArgumentException("blank or missing attribute: `date`");
+        }
+
+        // Create the instance.
+        return new ChangelogRelease(version, date);
+
+    }
+
+}
diff --git a/log4j-internal-util/src/main/java/org/apache/logging/log4j/internal/util/changelog/exporter/AsciiDocExporter.java b/log4j-internal-util/src/main/java/org/apache/logging/log4j/internal/util/changelog/exporter/AsciiDocExporter.java
new file mode 100644
index 0000000000..862642bcf9
--- /dev/null
+++ b/log4j-internal-util/src/main/java/org/apache/logging/log4j/internal/util/changelog/exporter/AsciiDocExporter.java
@@ -0,0 +1,394 @@
+/*
+ * 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.logging.log4j.internal.util.changelog.exporter;
+
+import org.apache.logging.log4j.internal.util.changelog.ChangelogEntry;
+import org.apache.logging.log4j.internal.util.changelog.ChangelogFiles;
+import org.apache.logging.log4j.internal.util.changelog.ChangelogRelease;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardOpenOption;
+import java.nio.file.attribute.FileTime;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+public final class AsciiDocExporter {
+
+    private static final String TARGET_RELATIVE_DIRECTORY = "src/site/asciidoc/changelog";
+
+    private static final Comparator<Path> FILE_MODIFICATION_TIME_COMPARATOR = Comparator.comparing(path -> {
+        try {
+            final FileTime fileTime = Files.getLastModifiedTime(path);
+            return fileTime.toMillis();
+        } catch (final IOException error) {
+            final String message = String.format("failed reading the last-modified time: `%s`", path);
+            throw new UncheckedIOException(message, error);
+        }
+    });
+
+    private static final String LICENSE_HEADER_ASCIIDOC = "////\n" +
+            "Licensed to the Apache Software Foundation (ASF) under one or more contributor license agreements.\n" +
+            "See the `NOTICE.txt` file distributed with this work for additional information regarding copyright ownership.\n" +
+            "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.\n" +
+            "You may obtain a copy of the License at [http://www.apache.org/licenses/LICENSE-2.0].\n" +
+            '\n' +
+            "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.\n" +
+            "See the License for the specific language governing permissions and limitations under the License.\n" +
+            "////\n";
+
+    private static final String AUTO_GENERATION_WARNING_ASCIIDOC = "////\n" +
+            "*DO NOT EDIT THIS FILE!!*\n" +
+            "This file is automatically generated from the release changelog directory!\n" +
+            "////\n";
+
+    private AsciiDocExporter() {}
+
+    public static void main(final String[] mainArgs) {
+
+        // Read arguments.
+        final AsciiDocExporterArgs args = AsciiDocExporterArgs.fromMainArgs(mainArgs);
+
+        // Find release directories.
+        final Path changelogDirectory = ChangelogFiles.changelogDirectory(args.projectRootDirectory);
+        final List<Path> releaseDirectories = findAdjacentFiles(changelogDirectory)
+                .sorted(FILE_MODIFICATION_TIME_COMPARATOR)
+                .collect(Collectors.toList());
+        final int releaseDirectoryCount = releaseDirectories.size();
+
+        // Read the release information files.
+        final List<ChangelogRelease> changelogReleases = releaseDirectories
+                .stream()
+                .map(releaseDirectory -> {
+                    final Path releaseXmlFile = ChangelogFiles.releaseXmlFile(releaseDirectory);
+                    return ChangelogRelease.readFromXmlFile(releaseXmlFile);
+                })
+                .collect(Collectors.toList());
+
+        // Export releases.
+        if (releaseDirectoryCount > 0) {
+
+            // Export each release directory.
+            for (int releaseIndex = 0; releaseIndex < releaseDirectories.size(); releaseIndex++) {
+                final Path releaseDirectory = releaseDirectories.get(releaseIndex);
+                final ChangelogRelease changelogRelease = changelogReleases.get(releaseIndex);
+                try {
+                    exportRelease(args.projectRootDirectory, releaseDirectory, changelogRelease);
+                } catch (final Exception error) {
+                    final String message =
+                            String.format("failed exporting release from directory `%s`", releaseDirectory);
+                    throw new RuntimeException(message, error);
+                }
+            }
+
+            // Report the operation.
+            if (releaseDirectoryCount == 1) {
+                System.out.format("exported a single release directory: `%s`%n", releaseDirectories.get(0));
+            } else {
+                System.out.format(
+                        "exported %d release directories: ..., `%s`%n",
+                        releaseDirectories.size(),
+                        releaseDirectories.get(releaseDirectoryCount - 1));
+            }
+
+        }
+
+        // Export the release index.
+        exportReleaseIndex(args.projectRootDirectory, changelogReleases);
+
+    }
+
+    /**
+     * Finds files non-recursively in the given directory.
+     * <p>
+     * Given directory itself and hidden files are filtered out.
+     * </p>
+     */
+    @SuppressWarnings("RedundantIfStatement")
+    private static Stream<Path> findAdjacentFiles(final Path directory) {
+        try {
+            return Files
+                    .walk(directory, 1)
+                    .filter(path -> {
+
+                        // Skip the directory itself.
+                        if (path.equals(directory)) {
+                            return false;
+                        }
+
+                        // Skip hidden files.
+                        boolean hiddenFile = path.getFileName().toString().startsWith(".");
+                        if (hiddenFile) {
+                            return false;
+                        }
+
+                        // Accept the rest.
+                        return true;
+
+                    });
+        } catch (final IOException error) {
+            final String message = String.format("failed walking directory: `%s`", directory);
+            throw new UncheckedIOException(message, error);
+        }
+    }
+
+    private static void exportRelease(
+            final Path projectRootDirectory,
+            final Path releaseDirectory,
+            final ChangelogRelease changelogRelease) {
+
+        // Read the changelog intro.
+        final String introAsciiDoc = readIntroAsciiDoc(releaseDirectory);
+
+        // Read the changelog entries.
+        final List<ChangelogEntry> changelogEntries = findAdjacentFiles(releaseDirectory)
+                .sorted(FILE_MODIFICATION_TIME_COMPARATOR)
+                .map(ChangelogEntry::readFromXmlFile)
+                .collect(Collectors.toList());
+
+        // Export the release.
+        try {
+            exportRelease(projectRootDirectory, changelogRelease, introAsciiDoc, changelogEntries);
+        } catch (final IOException error) {
+            final String message = String.format("failed exporting release from directory `%s`", releaseDirectory);
+            throw new UncheckedIOException(message, error);
+        }
+
+    }
+
+    private static String readIntroAsciiDoc(final Path releaseDirectory) {
+        final Path introAsciiDocFile = ChangelogFiles.introAsciiDocFile(releaseDirectory);
+        if (!Files.exists(introAsciiDocFile)) {
+            return "";
+        }
+        final byte[] introAsciiDocBytes;
+        try {
+            introAsciiDocBytes = Files.readAllBytes(introAsciiDocFile);
+        } catch (final IOException error) {
+            final String message = String.format("failed reading intro AsciiDoc file: `%s`", introAsciiDocFile);
+            throw new UncheckedIOException(message, error);
+        }
+        return new String(introAsciiDocBytes, StandardCharsets.UTF_8);
+    }
+
+    private static void exportRelease(
+            final Path projectRootDirectory,
+            final ChangelogRelease release,
+            final String introAsciiDoc,
+            final List<ChangelogEntry> entries)
+            throws IOException {
+        final String asciiDocFilename = changelogReleaseAsciiDocFilename(release);
+        final Path asciiDocFile = projectRootDirectory
+                .resolve(TARGET_RELATIVE_DIRECTORY)
+                .resolve(asciiDocFilename);
+        Files.createDirectories(asciiDocFile.getParent());
+        final String asciiDoc = exportReleaseToAsciiDoc(release, introAsciiDoc, entries);
+        final byte[] asciiDocBytes = asciiDoc.getBytes(StandardCharsets.UTF_8);
+        Files.write(asciiDocFile, asciiDocBytes, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
+    }
+
+    private static String exportReleaseToAsciiDoc(
+            final ChangelogRelease release,
+            final String introAsciiDoc,
+            final List<ChangelogEntry> entries) {
+
+        // Write the header.
+        final StringBuilder stringBuilder = new StringBuilder();
+        stringBuilder
+                .append(LICENSE_HEADER_ASCIIDOC)
+                .append('\n')
+                .append(AUTO_GENERATION_WARNING_ASCIIDOC)
+                .append('\n')
+                .append("= ")
+                .append(release.version)
+                .append(" (")
+                .append(release.date)
+                .append(")\n")
+                .append(introAsciiDoc)
+                .append("\n");
+
+        if (!entries.isEmpty()) {
+
+            stringBuilder.append("== Changes\n");
+
+            // Group entries by type.
+            final Map<ChangelogEntry.Type, List<ChangelogEntry>> entriesByType = entries
+                    .stream()
+                    .collect(Collectors.groupingBy(changelogEntry -> changelogEntry.type));
+
+            // Write entries for each type.
+            boolean[] firstEntryType = {true};
+            entriesByType
+                    .keySet()
+                    .stream()
+                    // Sorting is necessary for a consistent layout across different runs.
+                    .sorted()
+                    .forEach(type -> {
+                        stringBuilder.append('\n');
+                        appendEntryTypeHeader(stringBuilder, type);
+                        entriesByType.get(type).forEach(entry -> appendEntry(stringBuilder, entry));
+                    });
+
+        }
+
+        // Return the accumulated document so far.
+        return stringBuilder.toString();
+
+    }
+
+    private static void appendEntryTypeHeader(final StringBuilder stringBuilder, final ChangelogEntry.Type type) {
+        final String typeName = type.toString().toLowerCase(Locale.US);
+        final String header = typeName.substring(0, 1).toUpperCase(Locale.US) + typeName.substring(1);
+        stringBuilder
+                .append("=== ")
+                .append(header)
+                .append("\n\n");
+    }
+
+    private static void appendEntry(final StringBuilder stringBuilder, final ChangelogEntry entry) {
+        stringBuilder.append("* ");
+        appendEntryDescription(stringBuilder, entry.description);
+        final boolean containingIssues = !entry.issues.isEmpty();
+        final boolean containingAuthors = !entry.authors.isEmpty();
+        if (containingIssues || containingAuthors) {
+            stringBuilder.append(" (");
+            if (containingIssues) {
+                appendEntryIssues(stringBuilder, entry.issues);
+            }
+            if (containingIssues && containingAuthors) {
+                stringBuilder.append(' ');
+            }
+            if (containingAuthors) {
+                appendEntryAuthors(stringBuilder, entry.authors);
+            }
+            stringBuilder.append(")");
+        }
+        stringBuilder.append('\n');
+    }
+
+    private static void appendEntryDescription(
+            final StringBuilder stringBuilder,
+            final ChangelogEntry.Description description) {
+        if (!"asciidoc".equals(description.format)) {
+            final String message = String.format("unsupported description format: `%s`", description.format);
+            throw new RuntimeException(message);
+        }
+        stringBuilder.append(description.text);
+    }
+
+    private static void appendEntryIssues(
+            final StringBuilder stringBuilder,
+            final List<ChangelogEntry.Issue> issues) {
+        stringBuilder.append("for ");
+        final int issueCount = issues.size();
+        for (int issueIndex = 0; issueIndex < issueCount; issueIndex++) {
+            final ChangelogEntry.Issue issue = issues.get(issueIndex);
+            appendEntryIssue(stringBuilder, issue);
+            if ((issueIndex + 1) != issueCount) {
+                stringBuilder.append(", ");
+            }
+        }
+    }
+
+    private static void appendEntryIssue(final StringBuilder stringBuilder, final ChangelogEntry.Issue issue) {
+        stringBuilder
+                .append(issue.link)
+                .append('[')
+                .append(issue.id)
+                .append(']');
+    }
+
+    private static void appendEntryAuthors(
+            final StringBuilder stringBuilder,
+            final List<ChangelogEntry.Author> authors) {
+        stringBuilder.append("by ");
+        final int authorCount = authors.size();
+        for (int authorIndex = 0; authorIndex < authors.size(); authorIndex++) {
+            final ChangelogEntry.Author author = authors.get(authorIndex);
+            appendEntryAuthor(stringBuilder, author);
+            if ((authorIndex + 1) != authorCount) {
+                stringBuilder.append(", ");
+            }
+        }
+    }
+
+    private static void appendEntryAuthor(final StringBuilder stringBuilder, final ChangelogEntry.Author author) {
+        if (author.id != null) {
+            stringBuilder
+                    .append('`')
+                    .append(author.id)
+                    .append('`');
+        } else {
+            // Normalize author names written in `Doe, John` form.
+            if (author.name.contains(",")) {
+                String[] nameFields = author.name.split(",", 1);
+                stringBuilder.append(nameFields[1].trim());
+                stringBuilder.append(nameFields[0].trim());
+            } else {
+                stringBuilder.append(author.name);
+            }
+        }
+    }
+
+    private static void exportReleaseIndex(
+            final Path projectRootDirectory,
+            final List<ChangelogRelease> changelogReleases) {
+        final String asciiDoc = exportReleaseIndexToAsciiDoc(changelogReleases);
+        final byte[] asciiDocBytes = asciiDoc.getBytes(StandardCharsets.UTF_8);
+        final Path asciiDocFile = projectRootDirectory.resolve(TARGET_RELATIVE_DIRECTORY).resolve("index.adoc");
+        try {
+            Files.write(asciiDocFile, asciiDocBytes);
+        } catch (final IOException error) {
+            throw new UncheckedIOException(error);
+        }
+    }
+
+    private static String exportReleaseIndexToAsciiDoc(final List<ChangelogRelease> changelogReleases) {
+        final StringBuilder stringBuilder = new StringBuilder();
+        stringBuilder
+                .append(LICENSE_HEADER_ASCIIDOC)
+                .append('\n')
+                .append(AUTO_GENERATION_WARNING_ASCIIDOC)
+                .append("\n= Release changelogs\n\n");
+        for (int releaseIndex = changelogReleases.size() - 1; releaseIndex >= 0; releaseIndex--) {
+            final ChangelogRelease changelogRelease = changelogReleases.get(releaseIndex);
+            final String asciiDocFilename = changelogReleaseAsciiDocFilename(changelogRelease);
+            final String asciiDocBullet = String.format(
+                    "* [%s] xref:%s[%s]\n",
+                    changelogRelease.date,
+                    asciiDocFilename,
+                    changelogRelease.version);
+            stringBuilder.append(asciiDocBullet);
+        }
+        return stringBuilder.toString();
+    }
+
+    private static String changelogReleaseAsciiDocFilename(final ChangelogRelease changelogRelease) {
+        return String.format(
+                "%s-%s.adoc",
+                changelogRelease.date.replaceAll("-", ""),
+                changelogRelease.version);
+    }
+
+}
diff --git a/log4j-internal-util/src/main/java/org/apache/logging/log4j/internal/util/changelog/exporter/AsciiDocExporterArgs.java b/log4j-internal-util/src/main/java/org/apache/logging/log4j/internal/util/changelog/exporter/AsciiDocExporterArgs.java
new file mode 100644
index 0000000000..fb26a6f10f
--- /dev/null
+++ b/log4j-internal-util/src/main/java/org/apache/logging/log4j/internal/util/changelog/exporter/AsciiDocExporterArgs.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.logging.log4j.internal.util.changelog.exporter;
+
+import java.nio.file.Path;
+import java.nio.file.Paths;
+
+final class AsciiDocExporterArgs {
+
+    final Path projectRootDirectory;
+
+    private AsciiDocExporterArgs(final Path projectRootDirectory) {
+        this.projectRootDirectory = projectRootDirectory;
+    }
+
+    static AsciiDocExporterArgs fromMainArgs(final String[] args) {
+        if (args.length != 1) {
+            final String message = String.format("invalid number of arguments: %d, was expecting: <projectRootPath>", args.length);
+            throw new IllegalArgumentException(message);
+        }
+        final Path projectRootPath = Paths.get(args[0]);
+        return new AsciiDocExporterArgs(projectRootPath);
+    }
+
+}
diff --git a/log4j-internal-util/src/main/java/org/apache/logging/log4j/internal/util/changelog/importer/MavenChanges.java b/log4j-internal-util/src/main/java/org/apache/logging/log4j/internal/util/changelog/importer/MavenChanges.java
new file mode 100644
index 0000000000..5200ac1e16
--- /dev/null
+++ b/log4j-internal-util/src/main/java/org/apache/logging/log4j/internal/util/changelog/importer/MavenChanges.java
@@ -0,0 +1,210 @@
+/*
+ * 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.logging.log4j.internal.util.changelog.importer;
+
+import org.apache.logging.log4j.internal.util.XmlReader;
+import org.w3c.dom.Element;
+import org.w3c.dom.Node;
+import org.w3c.dom.NodeList;
+
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+
+import static org.apache.logging.log4j.internal.util.StringUtils.isBlank;
+import static org.apache.logging.log4j.internal.util.StringUtils.trimNullable;
+import static org.apache.logging.log4j.internal.util.XmlReader.failureAtXmlNode;
+import static org.apache.logging.log4j.internal.util.XmlReader.readXmlFileRootElement;
+
+final class MavenChanges {
+
+    final List<Release> releases;
+
+    private MavenChanges(final List<Release> releases) {
+        this.releases = releases;
+    }
+
+    static MavenChanges readFromProjectRootPath(final Path projectRootDirectory) {
+
+        // Read the root element.
+        final Path xmlPath = projectRootDirectory.resolve("src/changes/changes.xml");
+        final Element documentElement = readXmlFileRootElement(xmlPath, "document");
+
+        // Read the `body` element.
+        final NodeList bodyElements = documentElement.getElementsByTagName("body");
+        final int bodyElementCount = bodyElements.getLength();
+        if (bodyElementCount != 1) {
+            throw XmlReader.failureAtXmlNode(
+                    documentElement, "was expecting a single `body` element, found: %d", bodyElementCount);
+        }
+        final Node bodyElement = bodyElements.item(0);
+
+        // Read releases.
+        final List<Release> releases = new ArrayList<>();
+        final NodeList releaseNodes = bodyElement.getChildNodes();
+        final int releaseNodeCount = releaseNodes.getLength();
+        for (int releaseNodeIndex = 0; releaseNodeIndex < releaseNodeCount; releaseNodeIndex++) {
+            final Node releaseNode = releaseNodes.item(releaseNodeIndex);
+            if ("release".equals(releaseNode.getNodeName()) && Node.ELEMENT_NODE == releaseNode.getNodeType()) {
+                final Element releaseElement = (Element) releaseNode;
+                final Release release = Release.fromElement(releaseElement);
+                releases.add(release);
+            }
+        }
+
+        // Create the instance.
+        return new MavenChanges(releases);
+
+    }
+
+    static final class Release {
+
+        final String version;
+
+        final String date;
+
+        final List<Action> actions;
+
+        private Release(final String version, final String date, final List<Action> actions) {
+            this.version = version;
+            this.date = date;
+            this.actions = actions;
+        }
+
+        private static Release fromElement(final Element element) {
+
+            // Read `version`.
+            final String version = trimNullable(element.getAttribute("version"));
+            if (isBlank(version)) {
+                throw XmlReader.failureAtXmlNode(element, "blank attribute: `version`");
+            }
+
+            // Read `date`.
+            final String date = trimNullable(element.getAttribute("date"));
+            final String datePattern = "^(TBD|[0-9]{4}-[0-9]{2}-[0-9]{2})$";
+            if (!date.matches(datePattern)) {
+                throw XmlReader.failureAtXmlNode(element, "`date` doesn't match with the `%s` pattern: `%s`", datePattern, date);
+            }
+
+            // Read actions.
+            final List<Action> actions = new ArrayList<>();
+            final NodeList actionNodes = element.getChildNodes();
+            final int actionNodeCount = actionNodes.getLength();
+            for (int actionNodeIndex = 0; actionNodeIndex < actionNodeCount; actionNodeIndex++) {
+                final Node actionNode = actionNodes.item(actionNodeIndex);
+                if ("action".equals(actionNode.getNodeName()) && Node.ELEMENT_NODE == actionNode.getNodeType()) {
+                    Element actionElement = (Element) actionNode;
+                    Action action = Action.fromElement(actionElement);
+                    actions.add(action);
+                }
+            }
+
+            // Create the instance.
+            return new Release(version, date, actions);
+
+        }
+
+        @Override
+        public String toString() {
+            return version + " @ " + date;
+        }
+
+    }
+
+    static final class Action {
+
+        final String issue;
+
+        final Type type;
+
+        final String dev;
+
+        final String dueTo;
+
+        final String description;
+
+        enum Type {ADD, FIX, UPDATE, REMOVE}
+
+        private Action(
+                final String issue,
+                final Type type,
+                final String dev,
+                final String dueTo,
+                final String description) {
+            this.issue = issue;
+            this.type = type;
+            this.dev = dev;
+            this.dueTo = dueTo;
+            this.description = description;
+        }
+
+        private static Action fromElement(final Element element) {
+
+            // Read `issue`.
+            String issue = trimNullable(element.getAttribute("issue"));
+            final String issuePattern = "^LOG4J2-[0-9]+$";
+            if (isBlank(issue)) {
+                issue = null;
+            } else if (!issue.matches(issuePattern)) {
+                throw XmlReader.failureAtXmlNode(element, "`issue` doesn't match with the `%s` pattern: `%s`", issuePattern, issue);
+            }
+
+            // Read `type`.
+            final String typeString = trimNullable(element.getAttribute("type"));
+            final Type type;
+            if (isBlank(typeString)) {
+                type = Type.UPDATE;
+            } else {
+                try {
+                    type = Type.valueOf(typeString.toUpperCase(Locale.US));
+                } catch (IllegalArgumentException error) {
+                    throw failureAtXmlNode(error, element, "invalid type: `%s`", typeString);
+                }
+            }
+
+            // Read `dev`.
+            final String dev = trimNullable(element.getAttribute("dev"));
+            if (isBlank(dev)) {
+                throw XmlReader.failureAtXmlNode(element, "blank attribute: `dev`");
+            }
+
+            // Read `dueTo`.
+            String dueTo = trimNullable(element.getAttribute("dueTo"));
+            if (isBlank(dueTo)) {
+                dueTo = null;
+            }
+
+            // Read `description`.
+            final String description = trimNullable(element.getTextContent());
+            if (isBlank(description)) {
+                throw XmlReader.failureAtXmlNode(element, "blank `description`");
+            }
+
+            // Create the instance.
+            return new Action(issue, type, dev, dueTo, description);
+
+        }
+
+        @Override
+        public String toString() {
+            return issue != null ? issue : "unknown";
+        }
+
+    }
+
+}
diff --git a/log4j-internal-util/src/main/java/org/apache/logging/log4j/internal/util/changelog/importer/MavenChangesImporter.java b/log4j-internal-util/src/main/java/org/apache/logging/log4j/internal/util/changelog/importer/MavenChangesImporter.java
new file mode 100644
index 0000000000..61e83cee6a
--- /dev/null
+++ b/log4j-internal-util/src/main/java/org/apache/logging/log4j/internal/util/changelog/importer/MavenChangesImporter.java
@@ -0,0 +1,135 @@
+/*
+ * 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.logging.log4j.internal.util.changelog.importer;
+
+import org.apache.logging.log4j.internal.util.changelog.ChangelogEntry;
+import org.apache.logging.log4j.internal.util.changelog.ChangelogFiles;
+import org.apache.logging.log4j.internal.util.changelog.ChangelogRelease;
+
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+
+public final class MavenChangesImporter {
+
+    private MavenChangesImporter() {}
+
+    public static void main(final String[] mainArgs) {
+        final MavenChangesImporterArgs args = MavenChangesImporterArgs.fromMainArgs(mainArgs);
+        final MavenChanges mavenChanges = MavenChanges.readFromProjectRootPath(args.projectRootDirectory);
+        mavenChanges.releases.forEach(release -> {
+            if ("TBD".equals(release.date)) {
+                writeUnreleased(args.projectRootDirectory, release);
+            } else {
+                writeReleased(args.projectRootDirectory, release);
+            }
+        });
+    }
+
+    private static void writeUnreleased(final Path projectRootDirectory, final MavenChanges.Release release) {
+        final Path releaseDirectory = ChangelogFiles
+                .changelogDirectory(projectRootDirectory)
+                .resolve(".unreleased");
+        release.actions.forEach(action -> writeAction(releaseDirectory, action));
+    }
+
+    private static void writeReleased(final Path projectRootDirectory, final MavenChanges.Release release) {
+
+        // Determine the directory for this particular release.
+        final Path releaseDirectory = ChangelogFiles
+                .changelogDirectory(projectRootDirectory)
+                .resolve(String.format("%s-%s", release.date.replaceAll("-", ""), release.version));
+
+        // Write release information.
+        final Path releaseFile = ChangelogFiles.releaseXmlFile(releaseDirectory);
+        final ChangelogRelease changelogRelease = new ChangelogRelease(release.version, release.date);
+        changelogRelease.writeToXmlFile(releaseFile);
+
+        // Write release actions.
+        release.actions.forEach(action -> writeAction(releaseDirectory, action));
+
+    }
+
+    private static void writeAction(final Path releaseDirectory, final MavenChanges.Action action) {
+        final ChangelogEntry changelogEntry = changelogEntry(action);
+        final String changelogEntryFilename = changelogEntryFilename(action);
+        final Path changelogEntryFile = releaseDirectory.resolve(changelogEntryFilename);
+        changelogEntry.writeToXmlFile(changelogEntryFile);
+    }
+
+    private static String changelogEntryFilename(final MavenChanges.Action action) {
+        final StringBuilder actionRelativeFileBuilder = new StringBuilder();
+        if (action.issue != null) {
+            actionRelativeFileBuilder
+                    .append(action.issue)
+                    .append('_');
+        }
+        final String sanitizedDescription = action
+                .description
+                .substring(0, Math.min(action.description.length(), 60))
+                .replaceAll("[^A-Za-z0-9]", "_")
+                .replaceAll("_+", "_")
+                .replaceAll("[^A-Za-z0-9]$", "");
+        actionRelativeFileBuilder.append(sanitizedDescription);
+        actionRelativeFileBuilder.append(".xml");
+        return actionRelativeFileBuilder.toString();
+    }
+
+    private static ChangelogEntry changelogEntry(final MavenChanges.Action action) {
+
+        // Create the `type`.
+        final ChangelogEntry.Type type = changelogType(action.type);
+
+        // Create the `issue`s.
+        final List<ChangelogEntry.Issue> issues = new ArrayList<>(1);
+        if (action.issue != null) {
+            final String issueLink = String.format("https://issues.apache.org/jira/browse/%s", action.issue);
+            final ChangelogEntry.Issue issue = new ChangelogEntry.Issue(action.issue, issueLink);
+            issues.add(issue);
+        }
+
+        // Create the `author`s.
+        final List<ChangelogEntry.Author> authors = new ArrayList<>(2);
+        authors.add(new ChangelogEntry.Author(action.dev, null));
+        if (action.dueTo != null) {
+            authors.add(new ChangelogEntry.Author(null, action.dueTo));
+        }
+
+        // Create the `description`.
+        final ChangelogEntry.Description description = new ChangelogEntry.Description("asciidoc", action.description);
+
+        // Create the instance.
+        return new ChangelogEntry(type, issues, authors, description);
+
+    }
+
+    /**
+     * Maps `maven-changes-plugin` action types to their `Keep a Changelog` equivalents.
+     */
+    private static ChangelogEntry.Type changelogType(final MavenChanges.Action.Type type) {
+        if (MavenChanges.Action.Type.ADD.equals(type)) {
+            return ChangelogEntry.Type.ADDED;
+        } else if (MavenChanges.Action.Type.FIX.equals(type)) {
+            return ChangelogEntry.Type.FIXED;
+        } else if (MavenChanges.Action.Type.REMOVE.equals(type)) {
+            return ChangelogEntry.Type.REMOVED;
+        } else {
+            return ChangelogEntry.Type.CHANGED;
+        }
+    }
+
+}
diff --git a/log4j-internal-util/src/main/java/org/apache/logging/log4j/internal/util/changelog/importer/MavenChangesImporterArgs.java b/log4j-internal-util/src/main/java/org/apache/logging/log4j/internal/util/changelog/importer/MavenChangesImporterArgs.java
new file mode 100644
index 0000000000..7e01cd1e45
--- /dev/null
+++ b/log4j-internal-util/src/main/java/org/apache/logging/log4j/internal/util/changelog/importer/MavenChangesImporterArgs.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.logging.log4j.internal.util.changelog.importer;
+
+import java.nio.file.Path;
+import java.nio.file.Paths;
+
+final class MavenChangesImporterArgs {
+
+    final Path projectRootDirectory;
+
+    private MavenChangesImporterArgs(final Path projectRootDirectory) {
+        this.projectRootDirectory = projectRootDirectory;
+    }
+
+    static MavenChangesImporterArgs fromMainArgs(final String[] args) {
+        if (args.length != 1) {
+            final String s = String.format("invalid number of arguments: %d, was expecting: <projectRootPath>", args.length);
+            throw new IllegalArgumentException(s);
+        }
+        final Path projectRootPath = Paths.get(args[0]);
+        return new MavenChangesImporterArgs(projectRootPath);
+    }
+
+}
diff --git a/pom.xml b/pom.xml
index 1d70a46ca0..654254d8d8 100644
--- a/pom.xml
+++ b/pom.xml
@@ -1745,6 +1745,7 @@
     <module>log4j-to-slf4j</module>
     <module>log4j-to-jul</module>
     <module>log4j-web</module>
+    <module>log4j-internal-util</module>
   </modules>
   <profiles>
     <profile>