Browse Source

Drop version field from changelog YAML (#76985)

The changelog generation process currently relies on version
information being present in the changelog YAML descriptors. However,
this makes them difficult to update in some scenarios. For example,
if a PR is merged and subsequently labelled for backporting, our
automation won't update the versions in the changelog YAML.

We can make the process more flexible by removing version data from
the changelog YAML files, and instead inferring the versions from
each changelog YAML file's existence in the git tree at each tag
in the minor series.

This change makes the process more ergonomic for developers, but
harder to test, since I can't simply concoct YAML data for a range
of versions. Instead, I've added a number of unit tests, and tried
to exercise all the relevant parts.

It is now an error to include `versions` the YAML file.
Rory Hunter 4 years ago
parent
commit
ac803f078c
26 changed files with 1510 additions and 235 deletions
  1. 18 8
      build-tools-internal/src/main/java/org/elasticsearch/gradle/internal/release/BreakingChangesGenerator.java
  2. 4 15
      build-tools-internal/src/main/java/org/elasticsearch/gradle/internal/release/ChangelogEntry.java
  3. 193 40
      build-tools-internal/src/main/java/org/elasticsearch/gradle/internal/release/GenerateReleaseNotesTask.java
  4. 89 0
      build-tools-internal/src/main/java/org/elasticsearch/gradle/internal/release/GitWrapper.java
  5. 182 0
      build-tools-internal/src/main/java/org/elasticsearch/gradle/internal/release/QualifiedVersion.java
  6. 3 3
      build-tools-internal/src/main/java/org/elasticsearch/gradle/internal/release/ReleaseHighlightsGenerator.java
  7. 31 37
      build-tools-internal/src/main/java/org/elasticsearch/gradle/internal/release/ReleaseNotesGenerator.java
  8. 64 0
      build-tools-internal/src/main/java/org/elasticsearch/gradle/internal/release/ReleaseNotesIndexGenerator.java
  9. 0 97
      build-tools-internal/src/main/java/org/elasticsearch/gradle/internal/release/ReleaseNotesIndexUpdater.java
  10. 4 4
      build-tools-internal/src/main/java/org/elasticsearch/gradle/internal/release/ReleaseToolsPlugin.java
  11. 1 10
      build-tools-internal/src/main/resources/changelog-schema.json
  12. 1 1
      build-tools-internal/src/main/resources/templates/release-highlights.asciidoc
  13. 2 2
      build-tools-internal/src/main/resources/templates/release-notes-index.asciidoc
  14. 10 8
      build-tools-internal/src/main/resources/templates/release-notes.asciidoc
  15. 83 0
      build-tools-internal/src/test/java/org/elasticsearch/gradle/internal/release/BreakingChangesGeneratorTest.java
  16. 307 0
      build-tools-internal/src/test/java/org/elasticsearch/gradle/internal/release/GenerateReleaseNotesTaskTest.java
  17. 77 0
      build-tools-internal/src/test/java/org/elasticsearch/gradle/internal/release/ReleaseHighlightsGeneratorTest.java
  18. 126 0
      build-tools-internal/src/test/java/org/elasticsearch/gradle/internal/release/ReleaseNotesGeneratorTest.java
  19. 62 0
      build-tools-internal/src/test/java/org/elasticsearch/gradle/internal/release/ReleaseNotesIndexGeneratorTest.java
  20. 81 0
      build-tools-internal/src/test/resources/org/elasticsearch/gradle/internal/release/BreakingChangesGeneratorTest.generateFile.asciidoc
  21. 40 0
      build-tools-internal/src/test/resources/org/elasticsearch/gradle/internal/release/ReleaseHighlightsGeneratorTest.generateFile.asciidoc
  22. 105 0
      build-tools-internal/src/test/resources/org/elasticsearch/gradle/internal/release/ReleaseNotesGeneratorTest.generateFile.asciidoc
  23. 27 0
      build-tools-internal/src/test/resources/org/elasticsearch/gradle/internal/release/ReleaseNotesIndexGeneratorTest.generateFile.asciidoc
  24. 0 3
      docs/changelog/70635.yaml
  25. 0 4
      docs/changelog/75981.yaml
  26. 0 3
      docs/changelog/76192.yaml

+ 18 - 8
build-tools-internal/src/main/java/org/elasticsearch/gradle/internal/release/BreakingChangesGenerator.java

@@ -12,12 +12,12 @@ import groovy.text.SimpleTemplateEngine;
 
 import com.google.common.annotations.VisibleForTesting;
 
-import org.elasticsearch.gradle.Version;
 import org.elasticsearch.gradle.VersionProperties;
 
 import java.io.File;
 import java.io.FileWriter;
 import java.io.IOException;
+import java.io.Writer;
 import java.nio.file.Files;
 import java.util.HashMap;
 import java.util.List;
@@ -26,6 +26,9 @@ import java.util.Objects;
 import java.util.TreeMap;
 import java.util.stream.Collectors;
 
+import static java.util.Comparator.comparing;
+import static java.util.stream.Collectors.groupingBy;
+
 /**
  * Generates the page that lists the breaking changes and deprecations for a minor version release.
  */
@@ -33,33 +36,40 @@ public class BreakingChangesGenerator {
 
     static void update(File templateFile, File outputFile, List<ChangelogEntry> entries) throws IOException {
         try (FileWriter output = new FileWriter(outputFile)) {
-            generateFile(Files.readString(templateFile.toPath()), output, entries);
+            generateFile(
+                QualifiedVersion.of(VersionProperties.getElasticsearch()),
+                Files.readString(templateFile.toPath()),
+                output,
+                entries
+            );
         }
     }
 
     @VisibleForTesting
-    private static void generateFile(String template, FileWriter outputWriter, List<ChangelogEntry> entries) throws IOException {
-        final Version version = VersionProperties.getElasticsearchVersion();
+    static void generateFile(QualifiedVersion version, String template, Writer outputWriter, List<ChangelogEntry> entries)
+        throws IOException {
 
         final Map<Boolean, Map<String, List<ChangelogEntry.Breaking>>> breakingChangesByNotabilityByArea = entries.stream()
             .map(ChangelogEntry::getBreaking)
             .filter(Objects::nonNull)
+            .sorted(comparing(ChangelogEntry.Breaking::getTitle))
             .collect(
-                Collectors.groupingBy(
+                groupingBy(
                     ChangelogEntry.Breaking::isNotable,
-                    Collectors.groupingBy(ChangelogEntry.Breaking::getArea, TreeMap::new, Collectors.toList())
+                    groupingBy(ChangelogEntry.Breaking::getArea, TreeMap::new, Collectors.toList())
                 )
             );
 
         final Map<String, List<ChangelogEntry.Deprecation>> deprecationsByArea = entries.stream()
             .map(ChangelogEntry::getDeprecation)
             .filter(Objects::nonNull)
-            .collect(Collectors.groupingBy(ChangelogEntry.Deprecation::getArea, TreeMap::new, Collectors.toList()));
+            .sorted(comparing(ChangelogEntry.Deprecation::getTitle))
+            .collect(groupingBy(ChangelogEntry.Deprecation::getArea, TreeMap::new, Collectors.toList()));
 
         final Map<String, Object> bindings = new HashMap<>();
         bindings.put("breakingChangesByNotabilityByArea", breakingChangesByNotabilityByArea);
         bindings.put("deprecationsByArea", deprecationsByArea);
-        bindings.put("isElasticsearchSnapshot", VersionProperties.isElasticsearchSnapshot());
+        bindings.put("isElasticsearchSnapshot", version.isSnapshot());
         bindings.put("majorDotMinor", version.getMajor() + "." + version.getMinor());
         bindings.put("majorMinor", String.valueOf(version.getMajor()) + version.getMinor());
         bindings.put("nextMajor", (version.getMajor() + 1) + ".0");

+ 4 - 15
build-tools-internal/src/main/java/org/elasticsearch/gradle/internal/release/ChangelogEntry.java

@@ -37,7 +37,6 @@ public class ChangelogEntry {
     private Highlight highlight;
     private Breaking breaking;
     private Deprecation deprecation;
-    private List<String> versions;
 
     private static final ObjectMapper yamlMapper = new ObjectMapper(new YAMLFactory());
 
@@ -113,14 +112,6 @@ public class ChangelogEntry {
         this.deprecation = deprecation;
     }
 
-    public List<String> getVersions() {
-        return versions;
-    }
-
-    public void setVersions(List<String> versions) {
-        this.versions = versions;
-    }
-
     @Override
     public boolean equals(Object o) {
         if (this == o) {
@@ -136,20 +127,19 @@ public class ChangelogEntry {
             && Objects.equals(type, that.type)
             && Objects.equals(summary, that.summary)
             && Objects.equals(highlight, that.highlight)
-            && Objects.equals(breaking, that.breaking)
-            && Objects.equals(versions, that.versions);
+            && Objects.equals(breaking, that.breaking);
     }
 
     @Override
     public int hashCode() {
-        return Objects.hash(pr, issues, area, type, summary, highlight, breaking, versions);
+        return Objects.hash(pr, issues, area, type, summary, highlight, breaking);
     }
 
     @Override
     public String toString() {
         return String.format(
             Locale.ROOT,
-            "ChangelogEntry{pr=%d, issues=%s, area='%s', type='%s', summary='%s', highlight=%s, breaking=%s, deprecation=%s versions=%s}",
+            "ChangelogEntry{pr=%d, issues=%s, area='%s', type='%s', summary='%s', highlight=%s, breaking=%s, deprecation=%s}",
             pr,
             issues,
             area,
@@ -157,8 +147,7 @@ public class ChangelogEntry {
             summary,
             highlight,
             breaking,
-            deprecation,
-            versions
+            deprecation
         );
     }
 

+ 193 - 40
build-tools-internal/src/main/java/org/elasticsearch/gradle/internal/release/GenerateReleaseNotesTask.java

@@ -8,9 +8,11 @@
 
 package org.elasticsearch.gradle.internal.release;
 
-import org.elasticsearch.gradle.Version;
+import com.google.common.annotations.VisibleForTesting;
+
 import org.elasticsearch.gradle.VersionProperties;
 import org.gradle.api.DefaultTask;
+import org.gradle.api.GradleException;
 import org.gradle.api.file.ConfigurableFileCollection;
 import org.gradle.api.file.FileCollection;
 import org.gradle.api.file.RegularFile;
@@ -22,12 +24,23 @@ import org.gradle.api.tasks.InputFile;
 import org.gradle.api.tasks.InputFiles;
 import org.gradle.api.tasks.OutputFile;
 import org.gradle.api.tasks.TaskAction;
+import org.gradle.process.ExecOperations;
 
-import javax.inject.Inject;
+import java.io.File;
 import java.io.IOException;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Iterator;
 import java.util.List;
-import java.util.function.Predicate;
-import java.util.stream.Collectors;
+import java.util.Map;
+import java.util.Set;
+import javax.inject.Inject;
+
+import static java.util.Comparator.naturalOrder;
+import static java.util.stream.Collectors.toList;
+import static java.util.stream.Collectors.toSet;
 
 /**
  * Orchestrates the steps required to generate or update various release notes files.
@@ -47,8 +60,10 @@ public class GenerateReleaseNotesTask extends DefaultTask {
     private final RegularFileProperty releaseHighlightsFile;
     private final RegularFileProperty breakingChangesFile;
 
+    private final GitWrapper gitWrapper;
+
     @Inject
-    public GenerateReleaseNotesTask(ObjectFactory objectFactory) {
+    public GenerateReleaseNotesTask(ObjectFactory objectFactory, ExecOperations execOperations) {
         changelogs = objectFactory.fileCollection();
 
         releaseNotesIndexTemplate = objectFactory.fileProperty();
@@ -60,56 +75,194 @@ public class GenerateReleaseNotesTask extends DefaultTask {
         releaseNotesFile = objectFactory.fileProperty();
         releaseHighlightsFile = objectFactory.fileProperty();
         breakingChangesFile = objectFactory.fileProperty();
+
+        gitWrapper = new GitWrapper(execOperations);
     }
 
     @TaskAction
     public void executeTask() throws IOException {
+        if (needsGitTags(VersionProperties.getElasticsearch())) {
+            findAndUpdateUpstreamRemote(gitWrapper);
+        }
+
         LOGGER.info("Finding changelog files...");
 
-        final Version checkoutVersion = VersionProperties.getElasticsearchVersion();
+        final Map<QualifiedVersion, Set<File>> filesByVersion = partitionFilesByVersion(
+            gitWrapper,
+            VersionProperties.getElasticsearch(),
+            this.changelogs.getFiles()
+        );
 
-        final List<ChangelogEntry> entries = this.changelogs.getFiles()
-            .stream()
-            .map(ChangelogEntry::parse)
-            .filter(
-                // Only process changelogs that are included in this minor version series of ES.
-                // If this change was released in an earlier major or minor version of Elasticsearch, do not
-                // include it in the notes. An earlier patch version is OK, the release notes include changes
-                // for every patch release in a minor series.
-                log -> {
-                    final List<Version> versionsForChangelogFile = log.getVersions()
-                        .stream()
-                        .map(v -> Version.fromString(v, Version.Mode.RELAXED))
-                        .collect(Collectors.toList());
-
-                    final Predicate<Version> includedInSameMinor = v -> v.getMajor() == checkoutVersion.getMajor()
-                        && v.getMinor() == checkoutVersion.getMinor();
-
-                    final Predicate<Version> includedInEarlierMajorOrMinor = v -> v.getMajor() < checkoutVersion.getMajor()
-                        || (v.getMajor() == checkoutVersion.getMajor() && v.getMinor() < checkoutVersion.getMinor());
-
-                    boolean includedInThisMinor = versionsForChangelogFile.stream().anyMatch(includedInSameMinor);
-
-                    if (includedInThisMinor) {
-                        return versionsForChangelogFile.stream().noneMatch(includedInEarlierMajorOrMinor);
-                    } else {
-                        return false;
-                    }
-                }
-            )
-            .collect(Collectors.toList());
+        final List<ChangelogEntry> entries = new ArrayList<>();
+        final Map<QualifiedVersion, Set<ChangelogEntry>> changelogsByVersion = new HashMap<>();
+
+        filesByVersion.forEach((version, files) -> {
+            Set<ChangelogEntry> entriesForVersion = files.stream().map(ChangelogEntry::parse).collect(toSet());
+            entries.addAll(entriesForVersion);
+            changelogsByVersion.put(version, entriesForVersion);
+        });
+
+        final Set<QualifiedVersion> versions = getVersions(gitWrapper, VersionProperties.getElasticsearch());
 
         LOGGER.info("Updating release notes index...");
-        ReleaseNotesIndexUpdater.update(this.releaseNotesIndexTemplate.get().getAsFile(), this.releaseNotesIndexFile.get().getAsFile());
+        ReleaseNotesIndexGenerator.update(
+            versions,
+            this.releaseNotesIndexTemplate.get().getAsFile(),
+            this.releaseNotesIndexFile.get().getAsFile()
+        );
 
         LOGGER.info("Generating release notes...");
-        ReleaseNotesGenerator.update(this.releaseNotesTemplate.get().getAsFile(), this.releaseNotesFile.get().getAsFile(), entries);
+        ReleaseNotesGenerator.update(
+            this.releaseNotesTemplate.get().getAsFile(),
+            this.releaseNotesFile.get().getAsFile(),
+            changelogsByVersion
+        );
 
         LOGGER.info("Generating release highlights...");
-        ReleaseHighlightsGenerator.update(this.releaseHighlightsTemplate.get().getAsFile(), this.releaseHighlightsFile.get().getAsFile(), entries);
+        ReleaseHighlightsGenerator.update(
+            this.releaseHighlightsTemplate.get().getAsFile(),
+            this.releaseHighlightsFile.get().getAsFile(),
+            entries
+        );
 
         LOGGER.info("Generating breaking changes / deprecations notes...");
-        BreakingChangesGenerator.update(this.breakingChangesTemplate.get().getAsFile(), this.breakingChangesFile.get().getAsFile(), entries);
+        BreakingChangesGenerator.update(
+            this.breakingChangesTemplate.get().getAsFile(),
+            this.breakingChangesFile.get().getAsFile(),
+            entries
+        );
+    }
+
+    /**
+     * Find all tags in the `major.minor` series for the supplied version
+     * @param gitWrapper used to call `git`
+     * @param currentVersion the version to base the query upon
+     * @return all versions in the series
+     */
+    @VisibleForTesting
+    static Set<QualifiedVersion> getVersions(GitWrapper gitWrapper, String currentVersion) {
+        QualifiedVersion v = QualifiedVersion.of(currentVersion);
+        Set<QualifiedVersion> versions = gitWrapper.listVersions("v" + v.getMajor() + '.' + v.getMinor() + ".*").collect(toSet());
+        versions.add(v);
+        return versions;
+    }
+
+    /**
+     * Group a set of files by the version in which they first appeared, up until the supplied version. Any files not
+     * present in an earlier version are assumed to have been introduced in the specified version.
+     *
+     * <p>This method works by finding all git tags prior to {@param versionString} in the same minor series, and
+     * examining the git tree for that tag. By doing this over each tag, it is possible to see how the contents
+     * of the changelog directory changed over time.
+     *
+     * @param gitWrapper used to call `git`
+     * @param versionString the "current" version. Does not require a tag in git.
+     * @param allFilesInCheckout the files to partition
+     * @return a mapping from version to the files added in that version.
+     */
+    @VisibleForTesting
+    static Map<QualifiedVersion, Set<File>> partitionFilesByVersion(
+        GitWrapper gitWrapper,
+        String versionString,
+        Set<File> allFilesInCheckout
+    ) {
+        if (needsGitTags(versionString) == false) {
+            return Map.of(QualifiedVersion.of(versionString), allFilesInCheckout);
+        }
+
+        QualifiedVersion currentVersion = QualifiedVersion.of(versionString);
+
+        // Find all tags for this minor series, using a wildcard tag pattern.
+        String tagWildcard = "v%d.%d*".formatted(currentVersion.getMajor(), currentVersion.getMinor());
+
+        final List<QualifiedVersion> earlierVersions = gitWrapper.listVersions(tagWildcard)
+            // Only keep earlier versions, and if `currentVersion` is a prerelease, then only prereleases too.
+            .filter(each -> each.isBefore(currentVersion) && (currentVersion.hasQualifier() == each.hasQualifier()))
+            .sorted(naturalOrder())
+            .collect(toList());
+
+        if (earlierVersions.isEmpty()) {
+            throw new GradleException("Failed to find git tags prior to [v" + currentVersion + "]");
+        }
+
+        Map<QualifiedVersion, Set<File>> partitionedFiles = new HashMap<>();
+
+        Set<File> mutableAllFilesInCheckout = new HashSet<>(allFilesInCheckout);
+
+        // 1. For each earlier version
+        earlierVersions.forEach(earlierVersion -> {
+            // 2. Find all the changelog files it contained
+            Set<String> filesInTreeForVersion = gitWrapper.listFiles("v" + earlierVersion, "docs/changelog")
+                .map(line -> Path.of(line).getFileName().toString())
+                .collect(toSet());
+
+            Set<File> filesForVersion = new HashSet<>();
+            partitionedFiles.put(earlierVersion, filesForVersion);
+
+            // 3. Find the `File` object for each one
+            final Iterator<File> filesIterator = mutableAllFilesInCheckout.iterator();
+            while (filesIterator.hasNext()) {
+                File nextFile = filesIterator.next();
+                if (filesInTreeForVersion.contains(nextFile.getName())) {
+                    // 4. And remove it so that it is associated with the earlier version
+                    filesForVersion.add(nextFile);
+                    filesIterator.remove();
+                }
+            }
+        });
+
+        // 5. Associate whatever is left with the current version.
+        partitionedFiles.put(currentVersion, mutableAllFilesInCheckout);
+
+        return partitionedFiles;
+    }
+
+    /**
+     * Ensure the upstream git remote is up-to-date. The upstream is whatever git remote references `elastic/elasticsearch`.
+     * @param gitWrapper used to call `git`
+     */
+    private static void findAndUpdateUpstreamRemote(GitWrapper gitWrapper) {
+        LOGGER.info("Finding upstream git remote");
+        // We need to ensure the tags are up-to-date. Find the correct remote to use
+        String upstream = gitWrapper.listRemotes()
+            .entrySet()
+            .stream()
+            .filter(entry -> entry.getValue().contains("elastic/elasticsearch"))
+            .findFirst()
+            .map(Map.Entry::getKey)
+            .orElseThrow(
+                () -> new GradleException(
+                    "I need to ensure the git tags are up-to-date, but I couldn't find a git remote for [elastic/elasticsearch]"
+                )
+            );
+
+        LOGGER.info("Updating remote [{}]", upstream);
+        // Now update the remote, and make sure we update the tags too
+        gitWrapper.updateRemote(upstream);
+
+        LOGGER.info("Updating tags from [{}]", upstream);
+        gitWrapper.updateTags(upstream);
+    }
+
+    /**
+     * This methods checks the supplied version and answers {@code false} if the fetching of git
+     * tags can be skipped, or {@code true} otherwise.
+     * <p>
+     * The first version in a minor series will never have any preceding versions, so there's no
+     * need to fetch tags and examine the repository state in the past. This applies when the
+     * version is a release version, a snapshot, or the first alpha version. Subsequent alphas,
+     * betas and release candidates need to check the previous prelease tags.
+     *
+     * @param versionString the version string to check
+     * @return whether fetching git tags is required
+     */
+    @VisibleForTesting
+    static boolean needsGitTags(String versionString) {
+        if (versionString.endsWith(".0") || versionString.endsWith(".0-SNAPSHOT") || versionString.endsWith(".0-alpha1")) {
+            return false;
+        }
+
+        return true;
     }
 
     @InputFiles

+ 89 - 0
build-tools-internal/src/main/java/org/elasticsearch/gradle/internal/release/GitWrapper.java

@@ -0,0 +1,89 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+package org.elasticsearch.gradle.internal.release;
+
+import org.gradle.process.ExecOperations;
+
+import java.io.ByteArrayOutputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.Map;
+import java.util.Objects;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+/**
+ * This class wraps certain {@code git} operations. This is partly for convenience, and partly so that these
+ * operations can be easily mocked in testing.
+ */
+public class GitWrapper {
+
+    private final ExecOperations execOperations;
+
+    public GitWrapper(ExecOperations execOperations) {
+        this.execOperations = execOperations;
+    }
+
+    /**
+     * @return a mapping from remote names to remote URLs.
+     */
+    public Map<String, String> listRemotes() {
+        return runCommand("git", "remote", "-v").lines()
+            .filter(l -> l.contains("(fetch)"))
+            .map(line -> line.split("\\s+"))
+            .collect(Collectors.toMap(parts -> parts[0], parts -> parts[1]));
+    }
+
+    String runCommand(String... args) {
+        final ByteArrayOutputStream stdout = new ByteArrayOutputStream();
+
+        execOperations.exec(spec -> {
+            // The redundant cast is to silence a compiler warning.
+            spec.setCommandLine((Object[]) args);
+            spec.setStandardOutput(stdout);
+        });
+
+        return stdout.toString(StandardCharsets.UTF_8);
+    }
+
+    /**
+     * Updates the git repository from the specified remote
+     * @param remote the remote to use to update
+     */
+    public void updateRemote(String remote) {
+        runCommand("git", "fetch", Objects.requireNonNull(remote));
+    }
+
+    /**
+     * Updates the git repository's tags from the specified remote
+     * @param remote the remote to use to update
+     */
+    public void updateTags(String remote) {
+        runCommand("git", "fetch", "--tags", Objects.requireNonNull(remote));
+    }
+
+    /**
+     * Fetch all tags matching the specified pattern, returning them as {@link QualifiedVersion} instances.
+     * @param pattern the tag pattern to match
+     * @return matching versions
+     */
+    public Stream<QualifiedVersion> listVersions(String pattern) {
+        return runCommand("git", "tag", "-l", pattern).lines().map(QualifiedVersion::of);
+    }
+
+    /**
+     * Returns all files at the specified {@param path} for the state of the git repository at {@param ref}.
+     *
+     * @param ref the ref to use
+     * @param path the path to list
+     * @return A stream of file names. No path information is included.
+     */
+    public Stream<String> listFiles(String ref, String path) {
+        return runCommand("git", "ls-tree", "--name-only", "-r", ref, path).lines();
+    }
+}

+ 182 - 0
build-tools-internal/src/main/java/org/elasticsearch/gradle/internal/release/QualifiedVersion.java

@@ -0,0 +1,182 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+package org.elasticsearch.gradle.internal.release;
+
+import org.elasticsearch.gradle.Version;
+
+import java.util.Comparator;
+import java.util.Objects;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Encapsulates comparison and printing logic for an x.y.z version with optional qualifier. This class is very similar
+ * to {@link Version}, but it dissects the qualifier in such a way that is incompatible
+ * with how {@link Version} is used in the build. It also retains any qualifier (prerelease) information, and uses
+ * that information when comparing instances.
+ */
+public final class QualifiedVersion implements Comparable<QualifiedVersion> {
+    private final int major;
+    private final int minor;
+    private final int revision;
+    private final Qualifier qualifier;
+
+    private static final Pattern pattern = Pattern.compile(
+        "^v? (\\d+) \\. (\\d+) \\. (\\d+) (?: - (alpha\\d+ | beta\\d+ | rc\\d+ | SNAPSHOT ) )? $",
+        Pattern.COMMENTS
+    );
+
+    private QualifiedVersion(int major, int minor, int revision, String qualifier) {
+        this.major = major;
+        this.minor = minor;
+        this.revision = revision;
+        this.qualifier = qualifier == null ? null : Qualifier.of(qualifier);
+    }
+
+    /**
+     * Parses the supplied string into an object.
+     * @param s a version string in strict semver
+     * @return a new instance
+     */
+    public static QualifiedVersion of(final String s) {
+        Objects.requireNonNull(s);
+        Matcher matcher = pattern.matcher(s);
+        if (matcher.matches() == false) {
+            throw new IllegalArgumentException("Invalid version format: '" + s + "'. Should be " + pattern);
+        }
+
+        return new QualifiedVersion(
+            Integer.parseInt(matcher.group(1)),
+            Integer.parseInt(matcher.group(2)),
+            Integer.parseInt(matcher.group(3)),
+            matcher.group(4)
+        );
+    }
+
+    @Override
+    public String toString() {
+        return "%d.%d.%d%s".formatted(major, minor, revision, qualifier == null ? "" : "-" + qualifier);
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) return true;
+        if (o == null || getClass() != o.getClass()) return false;
+        QualifiedVersion version = (QualifiedVersion) o;
+        return major == version.major
+            && minor == version.minor
+            && revision == version.revision
+            && Objects.equals(qualifier, version.qualifier);
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(major, minor, revision, qualifier);
+    }
+
+    public int getMajor() {
+        return major;
+    }
+
+    public int getMinor() {
+        return minor;
+    }
+
+    public int getRevision() {
+        return revision;
+    }
+
+    public boolean hasQualifier() {
+        return qualifier != null;
+    }
+
+    public Qualifier getQualifier() {
+        return qualifier;
+    }
+
+    public boolean isSnapshot() {
+        return this.qualifier != null && this.qualifier.level == QualifierLevel.SNAPSHOT;
+    }
+
+    public QualifiedVersion withoutQualifier() {
+        return new QualifiedVersion(major, minor, revision, null);
+    }
+
+    private static final Comparator<QualifiedVersion> COMPARATOR = Comparator.comparing((QualifiedVersion v) -> v.major)
+        .thenComparing(v -> v.minor)
+        .thenComparing(v -> v.revision)
+        .thenComparing((QualifiedVersion v) -> v.qualifier, Comparator.nullsLast(Comparator.naturalOrder()));
+
+    @Override
+    public int compareTo(QualifiedVersion other) {
+        return COMPARATOR.compare(this, other);
+    }
+
+    public boolean isBefore(QualifiedVersion other) {
+        return this.compareTo(other) < 0;
+    }
+
+    private enum QualifierLevel {
+        alpha,
+        beta,
+        rc,
+        SNAPSHOT
+    }
+
+    private static class Qualifier implements Comparable<Qualifier> {
+        private final QualifierLevel level;
+        private final int number;
+
+        private Qualifier(QualifierLevel level, int number) {
+            this.level = level;
+            this.number = number;
+        }
+
+        private static final Comparator<Qualifier> COMPARATOR = Comparator.comparing((Qualifier p) -> p.level).thenComparing(p -> p.number);
+
+        @Override
+        public int compareTo(Qualifier other) {
+            return COMPARATOR.compare(this, other);
+        }
+
+        private static Qualifier of(String qualifier) {
+            if ("SNAPSHOT".equals(qualifier)) {
+                return new Qualifier(QualifierLevel.SNAPSHOT, 0);
+            }
+
+            Pattern pattern = Pattern.compile("^(alpha|beta|rc)(\\d+)$");
+            Matcher matcher = pattern.matcher(qualifier);
+            if (matcher.find()) {
+                String level = matcher.group(1);
+                int number = Integer.parseInt(matcher.group(2));
+                return new Qualifier(QualifierLevel.valueOf(level), number);
+            } else {
+                // This shouldn't happen - we check the format before this is called
+                throw new IllegalArgumentException("Invalid qualifier [" + qualifier + "] passed");
+            }
+        }
+
+        public String toString() {
+            return level == QualifierLevel.SNAPSHOT ? level.name() : level.name() + number;
+        }
+
+        @Override
+        public boolean equals(Object o) {
+            if (this == o) return true;
+            if (o == null || getClass() != o.getClass()) return false;
+            Qualifier that = (Qualifier) o;
+            return number == that.number && level == that.level;
+        }
+
+        @Override
+        public int hashCode() {
+            return Objects.hash(level, number);
+        }
+    }
+}

+ 3 - 3
build-tools-internal/src/main/java/org/elasticsearch/gradle/internal/release/ReleaseHighlightsGenerator.java

@@ -12,12 +12,12 @@ import groovy.text.SimpleTemplateEngine;
 
 import com.google.common.annotations.VisibleForTesting;
 
-import org.elasticsearch.gradle.Version;
 import org.elasticsearch.gradle.VersionProperties;
 
 import java.io.File;
 import java.io.FileWriter;
 import java.io.IOException;
+import java.io.Writer;
 import java.nio.file.Files;
 import java.util.ArrayList;
 import java.util.HashMap;
@@ -32,12 +32,12 @@ import java.util.stream.Collectors;
 public class ReleaseHighlightsGenerator {
     static void update(File templateFile, File outputFile, List<ChangelogEntry> entries) throws IOException {
         try (FileWriter output = new FileWriter(outputFile)) {
-            generateFile(VersionProperties.getElasticsearchVersion(), Files.readString(templateFile.toPath()), entries, output);
+            generateFile(QualifiedVersion.of(VersionProperties.getElasticsearch()), Files.readString(templateFile.toPath()), entries, output);
         }
     }
 
     @VisibleForTesting
-    static void generateFile(Version version, String templateFile, List<ChangelogEntry> entries, FileWriter outputWriter)
+    static void generateFile(QualifiedVersion version, String templateFile, List<ChangelogEntry> entries, Writer outputWriter)
         throws IOException {
         final List<String> priorVersions = new ArrayList<>();
 

+ 31 - 37
build-tools-internal/src/main/java/org/elasticsearch/gradle/internal/release/ReleaseNotesGenerator.java

@@ -8,10 +8,10 @@
 
 package org.elasticsearch.gradle.internal.release;
 
-import com.google.common.annotations.VisibleForTesting;
 import groovy.text.SimpleTemplateEngine;
-import org.elasticsearch.gradle.Version;
-import org.elasticsearch.gradle.VersionProperties;
+
+import com.google.common.annotations.VisibleForTesting;
+
 import org.gradle.api.GradleException;
 
 import java.io.File;
@@ -23,10 +23,14 @@ import java.util.Comparator;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 import java.util.TreeMap;
-import java.util.function.Predicate;
 import java.util.stream.Collectors;
 
+import static java.util.Comparator.comparing;
+import static java.util.stream.Collectors.groupingBy;
+import static java.util.stream.Collectors.toList;
+
 /**
  * Generates the release notes i.e. list of changes that have gone into this release. They are grouped by the
  * type of change, then by team area.
@@ -44,21 +48,23 @@ public class ReleaseNotesGenerator {
         TYPE_LABELS.put("deprecation", "Deprecations");
         TYPE_LABELS.put("enhancement", "Enhancements");
         TYPE_LABELS.put("feature", "New features");
+        TYPE_LABELS.put("new-aggregation", "New aggregation");
         TYPE_LABELS.put("regression", "Regressions");
         TYPE_LABELS.put("upgrade", "Upgrades");
     }
 
-    static void update(File templateFile, File outputFile, List<ChangelogEntry> changelogs) throws IOException {
+    static void update(File templateFile, File outputFile, Map<QualifiedVersion, Set<ChangelogEntry>> changelogs) throws IOException {
         final String templateString = Files.readString(templateFile.toPath());
 
         try (FileWriter output = new FileWriter(outputFile)) {
-            generateFile(VersionProperties.getElasticsearchVersion(), templateString, changelogs, output);
+            generateFile(templateString, changelogs, output);
         }
     }
 
     @VisibleForTesting
-    static void generateFile(Version version, String template, List<ChangelogEntry> changelogs, Writer outputWriter) throws IOException {
-        final var changelogsByVersionByTypeByArea = buildChangelogBreakdown(version, changelogs);
+    static void generateFile(String template, Map<QualifiedVersion, Set<ChangelogEntry>> changelogs, Writer outputWriter)
+        throws IOException {
+        final var changelogsByVersionByTypeByArea = buildChangelogBreakdown(changelogs);
 
         final Map<String, Object> bindings = new HashMap<>();
         bindings.put("changelogsByVersionByTypeByArea", changelogsByVersionByTypeByArea);
@@ -72,52 +78,40 @@ public class ReleaseNotesGenerator {
         }
     }
 
-    private static Map<Version, Map<String, Map<String, List<ChangelogEntry>>>> buildChangelogBreakdown(
-        Version elasticsearchVersion,
-        List<ChangelogEntry> changelogs
+    private static Map<QualifiedVersion, Map<String, Map<String, List<ChangelogEntry>>>> buildChangelogBreakdown(
+        Map<QualifiedVersion, Set<ChangelogEntry>> changelogsByVersion
     ) {
-        final Predicate<Version> includedInSameMinor = v -> v.getMajor() == elasticsearchVersion.getMajor()
-            && v.getMinor() == elasticsearchVersion.getMinor();
-
-        final Map<Version, Map<String, Map<String, List<ChangelogEntry>>>> changelogsByVersionByTypeByArea = changelogs.stream()
-            .collect(
-                Collectors.groupingBy(
-                    // Key changelog entries by the earlier version in which they were released
-                    entry -> entry.getVersions()
-                        .stream()
-                        .map(v -> Version.fromString(v.replaceFirst("^v", "")))
-                        .filter(includedInSameMinor)
-                        .sorted()
-                        .findFirst()
-                        .get(),
-
-                    // Generate a reverse-ordered map. Despite the IDE saying the type can be inferred, removing it
-                    // causes the compiler to complain.
-                    () -> new TreeMap<Version, Map<String, Map<String, List<ChangelogEntry>>>>(Comparator.reverseOrder()),
-
-                    // Group changelogs entries by their change type
-                    Collectors.groupingBy(
+        Map<QualifiedVersion, Map<String, Map<String, List<ChangelogEntry>>>> changelogsByVersionByTypeByArea = new TreeMap<>(
+            Comparator.reverseOrder()
+        );
+
+        changelogsByVersion.forEach((version, changelogs) -> {
+            Map<String, Map<String, List<ChangelogEntry>>> changelogsByTypeByArea = changelogs.stream()
+                .collect(
+                    groupingBy(
                         // Entries with breaking info are always put in the breaking section
                         entry -> entry.getBreaking() == null ? entry.getType() : "breaking",
                         TreeMap::new,
                         // Group changelogs for each type by their team area
-                        Collectors.groupingBy(
+                        groupingBy(
                             // `security` and `known-issue` areas don't need to supply an area
                             entry -> entry.getType().equals("known-issue") || entry.getType().equals("security")
                                 ? "_all_"
                                 : entry.getArea(),
                             TreeMap::new,
-                            Collectors.toList()
+                            toList()
                         )
                     )
-                )
-            );
+                );
+
+            changelogsByVersionByTypeByArea.put(version, changelogsByTypeByArea);
+        });
 
         // Sort per-area changelogs by their summary text. Assumes that the underlying list is sortable
         changelogsByVersionByTypeByArea.forEach(
             (_version, byVersion) -> byVersion.forEach(
                 (_type, byTeam) -> byTeam.forEach(
-                    (_team, changelogsForTeam) -> changelogsForTeam.sort(Comparator.comparing(ChangelogEntry::getSummary))
+                    (_team, changelogsForTeam) -> changelogsForTeam.sort(comparing(ChangelogEntry::getSummary))
                 )
             )
         );

+ 64 - 0
build-tools-internal/src/main/java/org/elasticsearch/gradle/internal/release/ReleaseNotesIndexGenerator.java

@@ -0,0 +1,64 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+package org.elasticsearch.gradle.internal.release;
+
+import groovy.text.SimpleTemplateEngine;
+
+import com.google.common.annotations.VisibleForTesting;
+
+import java.io.File;
+import java.io.FileWriter;
+import java.io.IOException;
+import java.io.Writer;
+import java.nio.file.Files;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeSet;
+import java.util.stream.Collectors;
+
+import static java.util.Comparator.reverseOrder;
+
+/**
+ * This class ensures that the release notes index page has the appropriate anchors and include directives
+ * for the current repository version.
+ */
+public class ReleaseNotesIndexGenerator {
+
+    static void update(Set<QualifiedVersion> versions, File indexTemplate, File indexFile) throws IOException {
+        try (FileWriter indexFileWriter = new FileWriter(indexFile)) {
+            generateFile(versions, Files.readString(indexTemplate.toPath()), indexFileWriter);
+        }
+    }
+
+    @VisibleForTesting
+    static void generateFile(Set<QualifiedVersion> versionsSet, String indexTemplate, Writer outputWriter) throws IOException {
+        final Set<QualifiedVersion> versions = new TreeSet<>(reverseOrder());
+
+        // For the purpose of generating the index, snapshot versions are the same as released versions. Prerelease versions are not.
+        versionsSet.stream().map(v -> v.isSnapshot() ? v.withoutQualifier() : v).forEach(versions::add);
+
+        final List<String> includeVersions = versions.stream()
+            .map(v -> v.hasQualifier() ? v.toString() : v.getMajor() + "." + v.getMinor())
+            .distinct()
+            .collect(Collectors.toList());
+
+        final Map<String, Object> bindings = new HashMap<>();
+        bindings.put("versions", versions);
+        bindings.put("includeVersions", includeVersions);
+
+        try {
+            final SimpleTemplateEngine engine = new SimpleTemplateEngine();
+            engine.createTemplate(indexTemplate).make(bindings).writeTo(outputWriter);
+        } catch (ClassNotFoundException e) {
+            throw new RuntimeException(e);
+        }
+    }
+}

+ 0 - 97
build-tools-internal/src/main/java/org/elasticsearch/gradle/internal/release/ReleaseNotesIndexUpdater.java

@@ -1,97 +0,0 @@
-/*
- * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
- * or more contributor license agreements. Licensed under the Elastic License
- * 2.0 and the Server Side Public License, v 1; you may not use this file except
- * in compliance with, at your election, the Elastic License 2.0 or the Server
- * Side Public License, v 1.
- */
-
-package org.elasticsearch.gradle.internal.release;
-
-import groovy.text.SimpleTemplateEngine;
-
-import com.google.common.annotations.VisibleForTesting;
-
-import org.elasticsearch.gradle.Version;
-import org.elasticsearch.gradle.VersionProperties;
-
-import java.io.File;
-import java.io.FileWriter;
-import java.io.IOException;
-import java.io.Writer;
-import java.nio.file.Files;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.stream.Collectors;
-
-/**
- * This class ensures that the release notes index page has the appropriate anchors and include directives
- * for the current repository version. It achieves this by parsing out the existing entries and writing
- * out the file again.
- */
-public class ReleaseNotesIndexUpdater {
-
-    static void update(File indexTemplate, File indexFile) throws IOException {
-        final List<String> existingIndexLines = Files.readAllLines(indexFile.toPath());
-        try (FileWriter indexFileWriter = new FileWriter(indexFile)) {
-            generateFile(
-                VersionProperties.getElasticsearchVersion(),
-                existingIndexLines,
-                Files.readString(indexTemplate.toPath()),
-                indexFileWriter
-            );
-        }
-    }
-
-    @VisibleForTesting
-    static void generateFile(Version version, List<String> existingIndexLines, String indexTemplate, Writer outputWriter)
-        throws IOException {
-        final List<String> existingVersions = existingIndexLines.stream()
-            .filter(line -> line.startsWith("* <<release-notes-"))
-            .map(line -> line.replace("* <<release-notes-", "").replace(">>", ""))
-            .distinct()
-            .collect(Collectors.toList());
-
-        final List<String> existingIncludes = existingIndexLines.stream()
-            .filter(line -> line.startsWith("include::"))
-            .map(line -> line.replace("include::release-notes/", "").replace(".asciidoc[]", ""))
-            .distinct()
-            .collect(Collectors.toList());
-
-        final String versionString = version.toString();
-
-        if (existingVersions.contains(versionString) == false) {
-            int insertionIndex = existingVersions.size() - 1;
-            while (insertionIndex > 0 && Version.fromString(existingVersions.get(insertionIndex)).before(version)) {
-                insertionIndex -= 1;
-            }
-            existingVersions.add(insertionIndex, versionString);
-        }
-
-        final String includeString = version.getMajor() + "." + version.getMinor();
-
-        if (existingIncludes.contains(includeString) == false) {
-            int insertionIndex = existingIncludes.size() - 1;
-            while (insertionIndex > 0 && Version.fromString(ensurePatchVersion(existingIncludes.get(insertionIndex))).before(version)) {
-                insertionIndex -= 1;
-            }
-            existingIncludes.add(insertionIndex, includeString);
-        }
-
-        final Map<String, Object> bindings = new HashMap<>();
-        bindings.put("existingVersions", existingVersions);
-        bindings.put("existingIncludes", existingIncludes);
-
-        try {
-            final SimpleTemplateEngine engine = new SimpleTemplateEngine();
-            engine.createTemplate(indexTemplate).make(bindings).writeTo(outputWriter);
-        } catch (ClassNotFoundException e) {
-            throw new RuntimeException(e);
-        }
-    }
-
-    private static String ensurePatchVersion(String version) {
-        return version.matches("^\\d+\\.\\d+\\.\\d+.*$") ? version : version + ".0";
-    }
-}

+ 4 - 4
build-tools-internal/src/main/java/org/elasticsearch/gradle/internal/release/ReleaseToolsPlugin.java

@@ -40,9 +40,11 @@ public class ReleaseToolsPlugin implements Plugin<Project> {
 
     @Override
     public void apply(Project project) {
-         project.getPluginManager().apply(PrecommitTaskPlugin.class);
+        project.getPluginManager().apply(PrecommitTaskPlugin.class);
         final Directory projectDirectory = projectLayout.getProjectDirectory();
 
+        final Version version = VersionProperties.getElasticsearchVersion();
+
         final FileTree yamlFiles = projectDirectory.dir("docs/changelog")
             .getAsFileTree()
             .matching(new PatternSet().include("**/*.yml", "**/*.yaml"));
@@ -65,8 +67,6 @@ public class ReleaseToolsPlugin implements Plugin<Project> {
             });
 
         project.getTasks().register("generateReleaseNotes", GenerateReleaseNotesTask.class).configure(task -> {
-            final Version version = VersionProperties.getElasticsearchVersion();
-
             task.setGroup("Documentation");
             task.setDescription("Generates release notes from changelog files held in this checkout");
             task.setChangelogs(yamlFiles);
@@ -92,6 +92,6 @@ public class ReleaseToolsPlugin implements Plugin<Project> {
             task.dependsOn(validateChangelogsTask);
         });
 
-         project.getTasks().named("precommit").configure(task -> task.dependsOn(validateChangelogsTask));
+        project.getTasks().named("precommit").configure(task -> task.dependsOn(validateChangelogsTask));
     }
 }

+ 1 - 10
build-tools-internal/src/main/resources/changelog-schema.json

@@ -103,14 +103,6 @@
           "type": "string",
           "minLength": 1
         },
-        "versions": {
-          "type": "array",
-          "items": {
-            "type": "string",
-            "pattern": "^v?\\d+\\.\\d+\\.\\d+$",
-            "minItems": 1
-          }
-        },
         "highlight": {
           "$ref": "#/definitions/Highlight"
         },
@@ -123,8 +115,7 @@
       },
       "required": [
         "type",
-        "summary",
-        "versions"
+        "summary"
       ]
     },
     "Highlight": {

+ 1 - 1
build-tools-internal/src/main/resources/templates/release-highlights.asciidoc

@@ -8,7 +8,7 @@ ifeval::[\\{release-state}\\"!=\\"unreleased\\"]
 For detailed information about this release, see the <<es-release-notes>> and
 <<breaking-changes>>.
 endif::[]
-<% if (priorVersions.size > 0) { %>
+<% if (priorVersions.size() > 0) { %>
 // Add previous release to the list
 Other versions:
 

+ 2 - 2
build-tools-internal/src/main/resources/templates/release-notes-index.asciidoc

@@ -6,7 +6,7 @@
 
 This section summarizes the changes in each release.
 
-<% existingVersions.each { print "* <<release-notes-${ it }>>\n" } %>
+<% versions.each { print "* <<release-notes-${ it }>>\n" } %>
 --
 
-<% existingIncludes.each { print "include::release-notes/${ it }.asciidoc[]\n" } %>
+<% includeVersions.each { print "include::release-notes/${ it }.asciidoc[]\n" } %>

+ 10 - 8
build-tools-internal/src/main/resources/templates/release-notes.asciidoc

@@ -1,13 +1,14 @@
 <% for (version in changelogsByVersionByTypeByArea.keySet()) {
-%>[[release-notes-$version]]
-== {es} version $version
-<% if (version.qualifier == "SNAPSHOT") { %>
-coming[$version]
+def unqualifiedVersion = version.withoutQualifier()
+%>[[release-notes-$unqualifiedVersion]]
+== {es} version ${unqualifiedVersion}
+<% if (version.isSnapshot()) { %>
+coming[$unqualifiedVersion]
 <% } %>
 Also see <<breaking-changes-${ version.major }.${ version.minor },Breaking changes in ${ version.major }.${ version.minor }>>.
 <% if (changelogsByVersionByTypeByArea[version]["security"] != null) { %>
 [discrete]
-[[security-updates-${version}]]
+[[security-updates-${unqualifiedVersion}]]
 === Security updates
 
 <% for (change in changelogsByVersionByTypeByArea[version].remove("security").remove("_all_")) {
@@ -16,7 +17,7 @@ Also see <<breaking-changes-${ version.major }.${ version.minor },Breaking chang
 }
 if (changelogsByVersionByTypeByArea[version]["known-issue"] != null) { %>
 [discrete]
-[[known-issues-${version}]]
+[[known-issues-${unqualifiedVersion}]]
 === Known issues
 
 <% for (change in changelogsByVersionByTypeByArea[version].remove("known-issue").remove("_all_")) {
@@ -24,9 +25,9 @@ if (changelogsByVersionByTypeByArea[version]["known-issue"] != null) { %>
 }
 }
 for (changeType in changelogsByVersionByTypeByArea[version].keySet()) { %>
-[[${ changeType }-${ version }]]
+[[${ changeType }-${ unqualifiedVersion }]]
 [float]
-=== ${ TYPE_LABELS[changeType] }
+=== ${ TYPE_LABELS.getOrDefault(changeType, 'No mapping for TYPE_LABELS[' + changeType + ']') }
 <% for (team in changelogsByVersionByTypeByArea[version][changeType].keySet()) {
     print "\n${team}::\n";
 
@@ -41,5 +42,6 @@ for (changeType in changelogsByVersionByTypeByArea[version].keySet()) { %>
     }
 }
 }
+print "\n\n"
 }
 %>

+ 83 - 0
build-tools-internal/src/test/java/org/elasticsearch/gradle/internal/release/BreakingChangesGeneratorTest.java

@@ -0,0 +1,83 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+package org.elasticsearch.gradle.internal.release;
+
+import org.junit.Test;
+
+import java.io.StringWriter;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.util.List;
+import java.util.Objects;
+
+import static org.hamcrest.Matchers.equalTo;
+import static org.junit.Assert.assertThat;
+
+public class BreakingChangesGeneratorTest {
+
+    /**
+     * Check that the breaking changes can be correctly generated.
+     */
+    @Test
+    public void generateFile_rendersCorrectMarkup() throws Exception {
+        // given:
+        final String template = getResource("/templates/breaking-changes.asciidoc");
+        final String expectedOutput = getResource(
+            "/org/elasticsearch/gradle/internal/release/BreakingChangesGeneratorTest.generateFile.asciidoc"
+        );
+        final StringWriter writer = new StringWriter();
+        final List<ChangelogEntry> entries = getEntries();
+
+        // when:
+        BreakingChangesGenerator.generateFile(QualifiedVersion.of("8.4.0-SNAPSHOT"), template, writer, entries);
+        final String actualOutput = writer.toString();
+
+        // then:
+        assertThat(actualOutput, equalTo(expectedOutput));
+    }
+
+    private List<ChangelogEntry> getEntries() {
+        ChangelogEntry entry1 = new ChangelogEntry();
+        ChangelogEntry.Breaking breaking1 = new ChangelogEntry.Breaking();
+        entry1.setBreaking(breaking1);
+
+        breaking1.setNotable(true);
+        breaking1.setTitle("Breaking change number 1");
+        breaking1.setArea("API");
+        breaking1.setDetails("Breaking change details 1");
+        breaking1.setImpact("Breaking change impact description 1");
+
+        ChangelogEntry entry2 = new ChangelogEntry();
+        ChangelogEntry.Breaking breaking2 = new ChangelogEntry.Breaking();
+        entry2.setBreaking(breaking2);
+
+        breaking2.setNotable(true);
+        breaking2.setTitle("Breaking change number 2");
+        breaking2.setArea("Cluster");
+        breaking2.setDetails("Breaking change details 2");
+        breaking2.setImpact("Breaking change impact description 2");
+
+        ChangelogEntry entry3 = new ChangelogEntry();
+        ChangelogEntry.Breaking breaking3 = new ChangelogEntry.Breaking();
+        entry3.setBreaking(breaking3);
+
+        breaking3.setNotable(false);
+        breaking3.setTitle("Breaking change number 3");
+        breaking3.setArea("Transform");
+        breaking3.setDetails("Breaking change details 3");
+        breaking3.setImpact("Breaking change impact description 3");
+
+        return List.of(entry1, entry2, entry3);
+    }
+
+    private String getResource(String name) throws Exception {
+        return Files.readString(Paths.get(Objects.requireNonNull(this.getClass().getResource(name)).toURI()), StandardCharsets.UTF_8);
+    }
+}

+ 307 - 0
build-tools-internal/src/test/java/org/elasticsearch/gradle/internal/release/GenerateReleaseNotesTaskTest.java

@@ -0,0 +1,307 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+package org.elasticsearch.gradle.internal.release;
+
+import org.elasticsearch.gradle.internal.test.GradleUnitTestCase;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.io.File;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import static org.hamcrest.Matchers.aMapWithSize;
+import static org.hamcrest.Matchers.allOf;
+import static org.hamcrest.Matchers.containsInAnyOrder;
+import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.hasEntry;
+import static org.hamcrest.Matchers.hasItem;
+import static org.hamcrest.Matchers.hasKey;
+import static org.hamcrest.Matchers.is;
+import static org.mockito.Matchers.anyString;
+import static org.mockito.Matchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyZeroInteractions;
+import static org.mockito.Mockito.when;
+
+public class GenerateReleaseNotesTaskTest extends GradleUnitTestCase {
+    private GitWrapper gitWrapper;
+
+    @Before
+    public void setup() {
+        this.gitWrapper = mock(GitWrapper.class);
+    }
+
+    /**
+     * Check that the task does not update git tags if the current version is a snapshot of the first patch release.
+     */
+    @Test
+    public void needsGitTags_withFirstSnapshot_returnsFalse() {
+        assertThat(GenerateReleaseNotesTask.needsGitTags("8.0.0-SNAPSHOT"), is(false));
+    }
+
+    /**
+     * Check that the task does update git tags if the current version is a snapshot after the first patch release.
+     */
+    @Test
+    public void needsGitTags_withLaterSnapshot_returnsTrue() {
+        assertThat(GenerateReleaseNotesTask.needsGitTags("8.0.1-SNAPSHOT"), is(true));
+    }
+
+    /**
+     * Check that the task does not update git tags if the current version is the first patch release in a minor series.
+     */
+    @Test
+    public void needsGitTags_withFirstPatchRelease_returnsFalse() {
+        assertThat(GenerateReleaseNotesTask.needsGitTags("8.0.0"), is(false));
+    }
+
+    /**
+     * Check that the task does update git tags if the current version is later than the first patch release in a minor series.
+     */
+    @Test
+    public void needsGitTags_withLaterPatchRelease_returnsTrue() {
+        assertThat(GenerateReleaseNotesTask.needsGitTags("8.0.1"), is(true));
+    }
+
+    /**
+     * Check that the task does not update git tags if the current version is a first alpha prerelease.
+     */
+    @Test
+    public void needsGitTags_withFirsAlphaRelease_returnsFalse() {
+        assertThat(GenerateReleaseNotesTask.needsGitTags("8.0.0-alpha1"), is(false));
+    }
+
+    /**
+     * Check that the task does update git tags if the current version is a prerelease after the first alpha.
+     */
+    @Test
+    public void needsGitTags_withLaterAlphaRelease_returnsFalse() {
+        assertThat(GenerateReleaseNotesTask.needsGitTags("8.0.0-alpha2"), is(true));
+    }
+
+    /**
+     * Check that partitioning changelog files when the current version is a snapshot returns a map with a single entry.
+     */
+    @Test
+    public void partitionFiles_withSnapshot_returnsSingleMapping() {
+        // when:
+        Map<QualifiedVersion, Set<File>> partitionedFiles = GenerateReleaseNotesTask.partitionFilesByVersion(
+            gitWrapper,
+            "8.0.0-SNAPSHOT",
+            Set.of(new File("docs/changelog/1234.yaml"))
+        );
+
+        // then:
+        assertThat(partitionedFiles, aMapWithSize(1));
+        assertThat(
+            partitionedFiles,
+            hasEntry(equalTo(QualifiedVersion.of("8.0.0-SNAPSHOT")), hasItem(new File("docs/changelog/1234.yaml")))
+        );
+        verifyZeroInteractions(gitWrapper);
+    }
+
+    /**
+     * Check that partitioning changelog files when the current version is the first release
+     * in a minor series returns a map with a single entry.
+     */
+    @Test
+    public void partitionFiles_withFirstRevision_returnsSingleMapping() {
+        // when:
+        Map<QualifiedVersion, Set<File>> partitionedFiles = GenerateReleaseNotesTask.partitionFilesByVersion(
+            gitWrapper,
+            "8.5.0",
+            Set.of(new File("docs/changelog/1234.yaml"))
+        );
+
+        // then:
+        assertThat(partitionedFiles, aMapWithSize(1));
+        assertThat(partitionedFiles, hasEntry(equalTo(QualifiedVersion.of("8.5.0")), hasItem(new File("docs/changelog/1234.yaml"))));
+        verifyZeroInteractions(gitWrapper);
+    }
+
+    /**
+     * Check that partitioning changelog files when the current version is the first alpha prerelease returns a map with a single entry.
+     */
+    @Test
+    public void partitionFiles_withFirstAlpha_returnsSingleMapping() {
+        // when:
+        Map<QualifiedVersion, Set<File>> partitionedFiles = GenerateReleaseNotesTask.partitionFilesByVersion(
+            gitWrapper,
+            "8.0.0-alpha1",
+            Set.of(new File("docs/changelog/1234.yaml"))
+        );
+
+        // then:
+        assertThat(partitionedFiles, aMapWithSize(1));
+        assertThat(partitionedFiles, hasEntry(equalTo(QualifiedVersion.of("8.0.0-alpha1")), hasItem(new File("docs/changelog/1234.yaml"))));
+        verifyZeroInteractions(gitWrapper);
+    }
+
+    /**
+     * Check that when deriving a lit of versions from git tags, the current unreleased version is included.
+     */
+    @Test
+    public void getVersions_includesCurrentVersion() {
+        // given:
+        when(gitWrapper.listVersions(anyString())).thenReturn(
+            Stream.of("8.0.0-alpha1", "8.0.0-alpha2", "8.0.0-beta1", "8.0.0-beta2", "8.0.0-beta3", "8.0.0-rc1", "8.0.0")
+                .map(QualifiedVersion::of)
+        );
+
+        // when:
+        Set<QualifiedVersion> versions = GenerateReleaseNotesTask.getVersions(gitWrapper, "8.0.0-SNAPSHOT");
+
+        // then:
+        assertThat(
+            versions,
+            containsInAnyOrder(
+                Stream.of(
+                    "8.0.0-alpha1",
+                    "8.0.0-alpha2",
+                    "8.0.0-beta1",
+                    "8.0.0-beta2",
+                    "8.0.0-beta3",
+                    "8.0.0-rc1",
+                    "8.0.0",
+                    "8.0.0-SNAPSHOT"
+                ).map(QualifiedVersion::of).collect(Collectors.toList()).toArray(new QualifiedVersion[] {})
+            )
+        );
+    }
+
+    /**
+     * Check that the task partitions the list of files correctly by version for a prerelease.
+     */
+    @Test
+    public void partitionFiles_withPrerelease_correctlyGroupsByPrereleaseVersion() {
+        // given:
+        when(gitWrapper.listVersions(anyString())).thenReturn(
+            Stream.of("8.0.0-alpha1", "8.0.0-alpha2", "8.0.0-beta1", "8.0.0-beta2", "8.0.0-beta3", "8.0.0-rc1", "8.0.0")
+                .map(QualifiedVersion::of)
+        );
+        when(gitWrapper.listFiles(eq("v8.0.0-alpha1"), anyString())).thenReturn(
+            Stream.of("docs/changelog/1_1234.yaml", "docs/changelog/1_5678.yaml")
+        );
+        when(gitWrapper.listFiles(eq("v8.0.0-alpha2"), anyString())).thenReturn(
+            Stream.of("docs/changelog/2_1234.yaml", "docs/changelog/2_5678.yaml")
+        );
+
+        Set<File> allFiles = Set.of(
+            new File("docs/changelog/1_1234.yaml"),
+            new File("docs/changelog/1_5678.yaml"),
+            new File("docs/changelog/2_1234.yaml"),
+            new File("docs/changelog/2_5678.yaml"),
+            new File("docs/changelog/3_1234.yaml"),
+            new File("docs/changelog/3_5678.yaml")
+        );
+
+        // when:
+        Map<QualifiedVersion, Set<File>> partitionedFiles = GenerateReleaseNotesTask.partitionFilesByVersion(gitWrapper, "8.0.0-beta1", allFiles);
+
+        // then:
+        verify(gitWrapper).listVersions("v8.0*");
+        verify(gitWrapper).listFiles("v8.0.0-alpha1", "docs/changelog");
+        verify(gitWrapper).listFiles("v8.0.0-alpha2", "docs/changelog");
+
+        assertThat(
+            partitionedFiles,
+            allOf(
+                aMapWithSize(3),
+                hasKey(QualifiedVersion.of("8.0.0-alpha1")),
+                hasKey(QualifiedVersion.of("8.0.0-alpha2")),
+                hasKey(QualifiedVersion.of("8.0.0-beta1"))
+            )
+        );
+
+        assertThat(
+            partitionedFiles,
+            allOf(
+                hasEntry(
+                    equalTo(QualifiedVersion.of("8.0.0-alpha1")),
+                    containsInAnyOrder(new File("docs/changelog/1_1234.yaml"), new File("docs/changelog/1_5678.yaml"))
+                ),
+                hasEntry(
+                    equalTo(QualifiedVersion.of("8.0.0-alpha2")),
+                    containsInAnyOrder(new File("docs/changelog/2_1234.yaml"), new File("docs/changelog/2_5678.yaml"))
+                ),
+                hasEntry(
+                    equalTo(QualifiedVersion.of("8.0.0-beta1")),
+                    containsInAnyOrder(new File("docs/changelog/3_1234.yaml"), new File("docs/changelog/3_5678.yaml"))
+                )
+            )
+        );
+    }
+
+    /**
+     * Check that the task partitions the list of files correctly by version for a patch release.
+     */
+    @Test
+    public void partitionFiles_withPatchRelease_correctlyGroupsByPatchVersion() {
+        // given:
+        when(gitWrapper.listVersions(anyString())).thenReturn(
+            Stream.of("8.0.0-alpha1", "8.0.0-alpha2", "8.0.0-beta1", "8.0.0-rc1", "8.0.0", "8.0.1", "8.0.2", "8.1.0")
+                .map(QualifiedVersion::of)
+        );
+        when(gitWrapper.listFiles(eq("v8.0.0"), anyString())).thenReturn(
+            Stream.of("docs/changelog/1_1234.yaml", "docs/changelog/1_5678.yaml")
+        );
+        when(gitWrapper.listFiles(eq("v8.0.1"), anyString())).thenReturn(
+            Stream.of("docs/changelog/2_1234.yaml", "docs/changelog/2_5678.yaml")
+        );
+
+        Set<File> allFiles = Set.of(
+            new File("docs/changelog/1_1234.yaml"),
+            new File("docs/changelog/1_5678.yaml"),
+            new File("docs/changelog/2_1234.yaml"),
+            new File("docs/changelog/2_5678.yaml"),
+            new File("docs/changelog/3_1234.yaml"),
+            new File("docs/changelog/3_5678.yaml")
+        );
+
+        // when:
+        Map<QualifiedVersion, Set<File>> partitionedFiles = GenerateReleaseNotesTask.partitionFilesByVersion(gitWrapper, "8.0.2", allFiles);
+
+        // then:
+        verify(gitWrapper).listVersions("v8.0*");
+        verify(gitWrapper).listFiles("v8.0.0", "docs/changelog");
+        verify(gitWrapper).listFiles("v8.0.1", "docs/changelog");
+
+        assertThat(
+            partitionedFiles,
+            allOf(
+                aMapWithSize(3),
+                hasKey(QualifiedVersion.of("8.0.0")),
+                hasKey(QualifiedVersion.of("8.0.1")),
+                hasKey(QualifiedVersion.of("8.0.2"))
+            )
+        );
+
+        assertThat(
+            partitionedFiles,
+            allOf(
+                hasEntry(
+                    equalTo(QualifiedVersion.of("8.0.0")),
+                    containsInAnyOrder(new File("docs/changelog/1_1234.yaml"), new File("docs/changelog/1_5678.yaml"))
+                ),
+                hasEntry(
+                    equalTo(QualifiedVersion.of("8.0.1")),
+                    containsInAnyOrder(new File("docs/changelog/2_1234.yaml"), new File("docs/changelog/2_5678.yaml"))
+                ),
+                hasEntry(
+                    equalTo(QualifiedVersion.of("8.0.2")),
+                    containsInAnyOrder(new File("docs/changelog/3_1234.yaml"), new File("docs/changelog/3_5678.yaml"))
+                )
+            )
+        );
+    }
+}

+ 77 - 0
build-tools-internal/src/test/java/org/elasticsearch/gradle/internal/release/ReleaseHighlightsGeneratorTest.java

@@ -0,0 +1,77 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+package org.elasticsearch.gradle.internal.release;
+
+import org.junit.Test;
+
+import java.io.StringWriter;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.util.List;
+import java.util.Objects;
+
+import static org.hamcrest.Matchers.equalTo;
+import static org.junit.Assert.assertThat;
+
+public class ReleaseHighlightsGeneratorTest {
+
+    /**
+     * Check that the release highlights can be correctly generated.
+     */
+    @Test
+    public void generateFile_rendersCorrectMarkup() throws Exception {
+        // given:
+        final String template = getResource("/templates/release-highlights.asciidoc");
+        final String expectedOutput = getResource(
+            "/org/elasticsearch/gradle/internal/release/ReleaseHighlightsGeneratorTest.generateFile.asciidoc"
+        );
+        final StringWriter writer = new StringWriter();
+        final List<ChangelogEntry> entries = getEntries();
+
+        // when:
+        ReleaseHighlightsGenerator.generateFile(QualifiedVersion.of("8.4.0-SNAPSHOT"), template, entries, writer);
+        final String actualOutput = writer.toString();
+
+        // then:
+        assertThat(actualOutput, equalTo(expectedOutput));
+    }
+
+    private List<ChangelogEntry> getEntries() {
+        ChangelogEntry entry1 = new ChangelogEntry();
+        ChangelogEntry.Highlight highlight1 = new ChangelogEntry.Highlight();
+        entry1.setHighlight(highlight1);
+
+        highlight1.setNotable(true);
+        highlight1.setTitle("Notable release highlight number 1");
+        highlight1.setBody("Notable release body number 1");
+
+        ChangelogEntry entry2 = new ChangelogEntry();
+        ChangelogEntry.Highlight highlight2 = new ChangelogEntry.Highlight();
+        entry2.setHighlight(highlight2);
+
+        highlight2.setNotable(true);
+        highlight2.setTitle("Notable release highlight number 2");
+        highlight2.setBody("Notable release body number 2");
+
+        ChangelogEntry entry3 = new ChangelogEntry();
+        ChangelogEntry.Highlight highlight3 = new ChangelogEntry.Highlight();
+        entry3.setHighlight(highlight3);
+
+        highlight3.setNotable(false);
+        highlight3.setTitle("Notable release highlight number 3");
+        highlight3.setBody("Notable release body number 3");
+
+        return List.of(entry1, entry2, entry3);
+    }
+
+    private String getResource(String name) throws Exception {
+        return Files.readString(Paths.get(Objects.requireNonNull(this.getClass().getResource(name)).toURI()), StandardCharsets.UTF_8);
+    }
+}

+ 126 - 0
build-tools-internal/src/test/java/org/elasticsearch/gradle/internal/release/ReleaseNotesGeneratorTest.java

@@ -0,0 +1,126 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+package org.elasticsearch.gradle.internal.release;
+
+import org.junit.Test;
+
+import java.io.StringWriter;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+
+import static org.hamcrest.Matchers.equalTo;
+import static org.junit.Assert.assertThat;
+
+public class ReleaseNotesGeneratorTest {
+
+    /**
+     * Check that the release notes can be correctly generated.
+     */
+    @Test
+    public void generateFile_rendersCorrectMarkup() throws Exception {
+        // given:
+        final String template = getResource("/templates/release-notes.asciidoc");
+        final String expectedOutput = getResource(
+            "/org/elasticsearch/gradle/internal/release/ReleaseNotesGeneratorTest.generateFile.asciidoc"
+        );
+        final StringWriter writer = new StringWriter();
+        final Map<QualifiedVersion, Set<ChangelogEntry>> entries = getEntries();
+
+        // when:
+        ReleaseNotesGenerator.generateFile(template, entries, writer);
+        final String actualOutput = writer.toString();
+
+        // then:
+        assertThat(actualOutput, equalTo(expectedOutput));
+    }
+
+    private Map<QualifiedVersion, Set<ChangelogEntry>> getEntries() {
+        final Set<ChangelogEntry> entries_8_2_0 = new HashSet<>();
+        entries_8_2_0.addAll(buildEntries(1, 2));
+        entries_8_2_0.addAll(buildEntries(2, 2));
+        entries_8_2_0.addAll(buildEntries(3, 2));
+
+        final Set<ChangelogEntry> entries_8_1_0 = new HashSet<>();
+        entries_8_1_0.addAll(buildEntries(4, 2));
+        entries_8_1_0.addAll(buildEntries(5, 2));
+        entries_8_1_0.addAll(buildEntries(6, 2));
+
+        final Set<ChangelogEntry> entries_8_0_0 = new HashSet<>();
+        entries_8_0_0.addAll(buildEntries(7, 2));
+        entries_8_0_0.addAll(buildEntries(8, 2));
+        entries_8_0_0.addAll(buildEntries(9, 2));
+
+        // Security issues are presented first in the notes
+        final ChangelogEntry securityEntry = new ChangelogEntry();
+        securityEntry.setArea("Security");
+        securityEntry.setType("security");
+        securityEntry.setSummary("Test security issue");
+        entries_8_2_0.add(securityEntry);
+
+        // known issues are presented after security issues
+        final ChangelogEntry knownIssue = new ChangelogEntry();
+        knownIssue.setArea("Search");
+        knownIssue.setType("known-issue");
+        knownIssue.setSummary("Test known issue");
+        entries_8_1_0.add(knownIssue);
+
+        final Map<QualifiedVersion, Set<ChangelogEntry>> result = new HashMap<>();
+
+        result.put(QualifiedVersion.of("8.2.0-SNAPSHOT"), entries_8_2_0);
+        result.put(QualifiedVersion.of("8.1.0"), entries_8_1_0);
+        result.put(QualifiedVersion.of("8.0.0"), entries_8_0_0);
+
+        return result;
+    }
+
+    private List<ChangelogEntry> buildEntries(int seed, int count) {
+        // Sample of possible areas from `changelog-schema.json`
+        final List<String> areas = List.of("Aggregation", "Cluster", "Indices", "Mappings", "Search", "Security");
+        // Possible change types, with `breaking`, `breaking-java`, `known-issue` and `security` removed.
+        final List<String> types = List.of("bug", "deprecation", "enhancement", "feature", "new-aggregation", "regression", "upgrade");
+
+        final String area = areas.get(seed % areas.size());
+        final String type = types.get(seed % types.size());
+
+        final List<ChangelogEntry> entries = new ArrayList<>(count);
+
+        int base = seed * 1000;
+
+        for (int i = 0; i < count; i++) {
+
+            final ChangelogEntry e = new ChangelogEntry();
+            e.setPr(base++);
+            e.setArea(area);
+            e.setSummary("Test changelog entry " + seed + "_" + i);
+            e.setType(type);
+
+            List<Integer> issues = new ArrayList<>(count);
+            for (int j = 0; j <= i; j++) {
+                issues.add(base++);
+            }
+            e.setIssues(issues);
+
+            entries.add(e);
+        }
+
+        return entries;
+    }
+
+    private String getResource(String name) throws Exception {
+        return Files.readString(Paths.get(Objects.requireNonNull(this.getClass().getResource(name)).toURI()), StandardCharsets.UTF_8);
+    }
+}

+ 62 - 0
build-tools-internal/src/test/java/org/elasticsearch/gradle/internal/release/ReleaseNotesIndexGeneratorTest.java

@@ -0,0 +1,62 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+package org.elasticsearch.gradle.internal.release;
+
+import org.junit.Test;
+
+import java.io.StringWriter;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.util.Objects;
+import java.util.Set;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import static org.hamcrest.Matchers.equalTo;
+import static org.junit.Assert.assertThat;
+
+public class ReleaseNotesIndexGeneratorTest {
+
+    /**
+     * Check that a release notes index can be generated.
+     */
+    @Test
+    public void generateFile_rendersCorrectMarkup() throws Exception {
+        // given:
+        final Set<QualifiedVersion> versions = Stream.of(
+            "8.0.0-alpha1",
+            "8.0.0-beta2",
+            "8.0.0-rc3",
+            "8.0.0",
+            "8.0.1",
+            "8.0.2",
+            "8.1.0",
+            "8.1.1",
+            "8.2.0-SNAPSHOT"
+        ).map(QualifiedVersion::of).collect(Collectors.toSet());
+
+        final String template = getResource("/templates/release-notes-index.asciidoc");
+        final String expectedOutput = getResource(
+            "/org/elasticsearch/gradle/internal/release/ReleaseNotesIndexGeneratorTest.generateFile.asciidoc"
+        );
+        final StringWriter writer = new StringWriter();
+
+        // when:
+        ReleaseNotesIndexGenerator.generateFile(versions, template, writer);
+        final String actualOutput = writer.toString();
+
+        // then:
+        assertThat(actualOutput, equalTo(expectedOutput));
+    }
+
+    private String getResource(String name) throws Exception {
+        return Files.readString(Paths.get(Objects.requireNonNull(this.getClass().getResource(name)).toURI()), StandardCharsets.UTF_8);
+    }
+}

+ 81 - 0
build-tools-internal/src/test/resources/org/elasticsearch/gradle/internal/release/BreakingChangesGeneratorTest.generateFile.asciidoc

@@ -0,0 +1,81 @@
+[[migrating-8.4]]
+== Migrating to 8.4
+++++
+<titleabbrev>8.4</titleabbrev>
+++++
+
+This section discusses the changes that you need to be aware of when migrating
+your application to {es} 8.4.
+
+See also <<release-highlights>> and <<es-release-notes>>.
+
+coming[8.4.0-SNAPSHOT]
+
+//NOTE: The notable-breaking-changes tagged regions are re-used in the
+//Installation and Upgrade Guide
+
+[discrete]
+[[breaking-changes-8.4]]
+=== Breaking changes
+
+The following changes in {es} 8.4 might affect your applications
+and prevent them from operating normally.
+Before upgrading to 8.4 review these changes and take the described steps
+to mitigate the impact.
+
+NOTE: Breaking changes introduced in minor versions are
+normally limited to security and bug fixes.
+Significant changes in behavior are deprecated in a minor release and
+the old behavior is supported until the next major release.
+To find out if you are using any deprecated functionality,
+enable <<deprecation-logging, deprecation logging>>.
+
+// tag::notable-breaking-changes[]
+[discrete]
+[[breaking_84_api]]
+==== API
+
+[[breaking_change_number_1]]
+.Breaking change number 1
+[%collapsible]
+====
+*Details* +
+Breaking change details 1
+
+*Impact* +
+Breaking change impact description 1
+====
+// end::notable-breaking-changes[]
+
+// tag::notable-breaking-changes[]
+[discrete]
+[[breaking_84_cluster]]
+==== Cluster
+
+[[breaking_change_number_2]]
+.Breaking change number 2
+[%collapsible]
+====
+*Details* +
+Breaking change details 2
+
+*Impact* +
+Breaking change impact description 2
+====
+// end::notable-breaking-changes[]
+
+[discrete]
+[[breaking_84_transform]]
+==== Transform
+
+[[breaking_change_number_3]]
+.Breaking change number 3
+[%collapsible]
+====
+*Details* +
+Breaking change details 3
+
+*Impact* +
+Breaking change impact description 3
+====
+

+ 40 - 0
build-tools-internal/src/test/resources/org/elasticsearch/gradle/internal/release/ReleaseHighlightsGeneratorTest.generateFile.asciidoc

@@ -0,0 +1,40 @@
+[[release-highlights]]
+== What's new in {minor-version}
+
+coming::[{minor-version}]
+
+Here are the highlights of what's new and improved in {es} {minor-version}!
+ifeval::[\{release-state}\"!=\"unreleased\"]
+For detailed information about this release, see the <<es-release-notes>> and
+<<breaking-changes>>.
+endif::[]
+
+// Add previous release to the list
+Other versions:
+
+{ref-bare}/8.4/release-highlights.html[8.4]
+| {ref-bare}/8.3/release-highlights.html[8.3]
+| {ref-bare}/8.2/release-highlights.html[8.2]
+| {ref-bare}/8.1/release-highlights.html[8.1]
+| {ref-bare}/8.0/release-highlights.html[8.0]
+
+// tag::notable-highlights[]
+
+[discrete]
+[[notable_release_highlight_number_1]]
+=== Notable release highlight number 1
+Notable release body number 1
+
+[discrete]
+[[notable_release_highlight_number_2]]
+=== Notable release highlight number 2
+Notable release body number 2
+
+// end::notable-highlights[]
+
+
+[discrete]
+[[notable_release_highlight_number_3]]
+=== Notable release highlight number 3
+Notable release body number 3
+

+ 105 - 0
build-tools-internal/src/test/resources/org/elasticsearch/gradle/internal/release/ReleaseNotesGeneratorTest.generateFile.asciidoc

@@ -0,0 +1,105 @@
+[[release-notes-8.2.0]]
+== {es} version 8.2.0
+
+coming[8.2.0]
+
+Also see <<breaking-changes-8.2,Breaking changes in 8.2>>.
+
+[discrete]
+[[security-updates-8.2.0]]
+=== Security updates
+
+* Test security issue
+
+[[deprecation-8.2.0]]
+[float]
+=== Deprecations
+
+Cluster::
+* Test changelog entry 1_0 {es-pull}1000[#1000] (issue: {es-issue}1001[#1001])
+* Test changelog entry 1_1 {es-pull}1002[#1002] (issues: {es-issue}1003[#1003], {es-issue}1004[#1004])
+
+[[enhancement-8.2.0]]
+[float]
+=== Enhancements
+
+Indices::
+* Test changelog entry 2_0 {es-pull}2000[#2000] (issue: {es-issue}2001[#2001])
+* Test changelog entry 2_1 {es-pull}2002[#2002] (issues: {es-issue}2003[#2003], {es-issue}2004[#2004])
+
+[[feature-8.2.0]]
+[float]
+=== New features
+
+Mappings::
+* Test changelog entry 3_0 {es-pull}3000[#3000] (issue: {es-issue}3001[#3001])
+* Test changelog entry 3_1 {es-pull}3002[#3002] (issues: {es-issue}3003[#3003], {es-issue}3004[#3004])
+
+
+[[release-notes-8.1.0]]
+== {es} version 8.1.0
+
+Also see <<breaking-changes-8.1,Breaking changes in 8.1>>.
+
+[discrete]
+[[known-issues-8.1.0]]
+=== Known issues
+
+* Test known issue
+
+[[new-aggregation-8.1.0]]
+[float]
+=== New aggregation
+
+Search::
+* Test changelog entry 4_0 {es-pull}4000[#4000] (issue: {es-issue}4001[#4001])
+* Test changelog entry 4_1 {es-pull}4002[#4002] (issues: {es-issue}4003[#4003], {es-issue}4004[#4004])
+
+[[regression-8.1.0]]
+[float]
+=== Regressions
+
+Security::
+* Test changelog entry 5_0 {es-pull}5000[#5000] (issue: {es-issue}5001[#5001])
+* Test changelog entry 5_1 {es-pull}5002[#5002] (issues: {es-issue}5003[#5003], {es-issue}5004[#5004])
+
+[[upgrade-8.1.0]]
+[float]
+=== Upgrades
+
+Aggregation::
+* Test changelog entry 6_0 {es-pull}6000[#6000] (issue: {es-issue}6001[#6001])
+* Test changelog entry 6_1 {es-pull}6002[#6002] (issues: {es-issue}6003[#6003], {es-issue}6004[#6004])
+
+
+[[release-notes-8.0.0]]
+== {es} version 8.0.0
+
+Also see <<breaking-changes-8.0,Breaking changes in 8.0>>.
+
+[[bug-8.0.0]]
+[float]
+=== Bug fixes
+
+Cluster::
+* Test changelog entry 7_0 {es-pull}7000[#7000] (issue: {es-issue}7001[#7001])
+* Test changelog entry 7_1 {es-pull}7002[#7002] (issues: {es-issue}7003[#7003], {es-issue}7004[#7004])
+
+[[deprecation-8.0.0]]
+[float]
+=== Deprecations
+
+Indices::
+* Test changelog entry 8_0 {es-pull}8000[#8000] (issue: {es-issue}8001[#8001])
+* Test changelog entry 8_1 {es-pull}8002[#8002] (issues: {es-issue}8003[#8003], {es-issue}8004[#8004])
+
+[[enhancement-8.0.0]]
+[float]
+=== Enhancements
+
+Mappings::
+* Test changelog entry 9_0 {es-pull}9000[#9000] (issue: {es-issue}9001[#9001])
+* Test changelog entry 9_1 {es-pull}9002[#9002] (issues: {es-issue}9003[#9003], {es-issue}9004[#9004])
+
+
+

+ 27 - 0
build-tools-internal/src/test/resources/org/elasticsearch/gradle/internal/release/ReleaseNotesIndexGeneratorTest.generateFile.asciidoc

@@ -0,0 +1,27 @@
+[[es-release-notes]]
+= Release notes
+
+[partintro]
+--
+
+This section summarizes the changes in each release.
+
+* <<release-notes-8.2.0>>
+* <<release-notes-8.1.1>>
+* <<release-notes-8.1.0>>
+* <<release-notes-8.0.2>>
+* <<release-notes-8.0.1>>
+* <<release-notes-8.0.0>>
+* <<release-notes-8.0.0-rc3>>
+* <<release-notes-8.0.0-beta2>>
+* <<release-notes-8.0.0-alpha1>>
+
+--
+
+include::release-notes/8.2.asciidoc[]
+include::release-notes/8.1.asciidoc[]
+include::release-notes/8.0.asciidoc[]
+include::release-notes/8.0.0-rc3.asciidoc[]
+include::release-notes/8.0.0-beta2.asciidoc[]
+include::release-notes/8.0.0-alpha1.asciidoc[]
+

+ 0 - 3
docs/changelog/70635.yaml

@@ -3,6 +3,3 @@ summary: Tighten up write permissions in Docker image
 area: Packaging
 type: enhancement
 issues: []
-versions:
- - v8.0.0
- - v7.15.0

+ 0 - 4
docs/changelog/75981.yaml

@@ -3,7 +3,3 @@ summary: Bump bundled JDK to 16.0.2
 area: Packaging
 type: upgrade
 issues: []
-versions:
- - v8.0.0
- - v7.14.1
- - v7.15.0

+ 0 - 3
docs/changelog/76192.yaml

@@ -5,6 +5,3 @@ type: enhancement
 issues:
  - 76148
  - 74327
-versions:
- - v8.0.0
- - v7.15.0