diff --git a/Gemfile b/Gemfile
index 3af1143e5d0bc4b73bc9d5dd83f240c15f1ee992..432040ec096a1062659eac70288ab241cb48225f 100644
--- a/Gemfile
+++ b/Gemfile
@@ -519,3 +519,5 @@ gem 'webauthn', '~> 2.3'
 
 # IPAddress utilities
 gem 'ipaddress', '~> 0.8.3'
+
+gem 'parslet', '~> 1.8'
diff --git a/Gemfile.lock b/Gemfile.lock
index 39850135e9a14a8ff4a20d83686d538f873350af..adb7c432815c494737bb14ea8e285b27c2b028c4 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -1464,6 +1464,7 @@ DEPENDENCIES
   omniauth_openid_connect (~> 0.3.5)
   org-ruby (~> 0.9.12)
   parallel (~> 1.19)
+  parslet (~> 1.8)
   peek (~> 1.1)
   pg (~> 1.1)
   pg_query (~> 1.3.0)
diff --git a/doc/api/repositories.md b/doc/api/repositories.md
index 6bbd4c56e4012208abd19b25f7d60eb1f96b538a..7d7d15709d85cea6a15f94f94f7218c7742b515c 100644
--- a/doc/api/repositories.md
+++ b/doc/api/repositories.md
@@ -410,6 +410,7 @@ follows:
 - [{{ title }}]({{ commit.reference }})\
 {% if author.contributor %} by {{ author.reference }}{% end %}\
 {% if merge_request %} ([merge request]({{ merge_request.reference }})){% end %}
+
 {% end %}
 
 {% end %}
@@ -457,11 +458,40 @@ If a line ends in a backslash, the next newline is ignored. This allows you to
 wrap code across multiple lines, without introducing unnecessary newlines in the
 Markdown output.
 
+Tags that use `{%` and `%}` (known as expression tags) consume the newline that
+directly follows them, if any. This means that this:
+
+```plaintext
+---
+{% if foo %}
+bar
+{% end %}
+---
+```
+
+Compiles into this:
+
+```plaintext
+---
+bar
+---
+```
+
+Instead of this:
+
+```plaintext
+---
+
+bar
+
+---
+```
+
 You can specify a custom template in your configuration like so:
 
 ```yaml
 ---
-template: >
+template: |
   {% if categories %}
   {% each categories %}
   ### {{ title }}
@@ -469,6 +499,7 @@ template: >
   {% each entries %}
   - [{{ title }}]({{ commit.reference }})\
   {% if author.contributor %} by {{ author.reference }}{% end %}
+
   {% end %}
 
   {% end %}
@@ -477,6 +508,9 @@ template: >
   {% end %}
 ```
 
+Note that when specifying the template you should use `template: |` and not
+`template: >`, as the latter doesn't preserve newlines in the template.
+
 ### Template data
 
 At the top level, the following variable is available:
diff --git a/lib/api/repositories.rb b/lib/api/repositories.rb
index 353f2ed1c2541456d06cc2218de20802a8986a92..d0e81a947d9676540bd1f28fc57b179bfa6443d8 100644
--- a/lib/api/repositories.rb
+++ b/lib/api/repositories.rb
@@ -228,7 +228,7 @@ def assign_blob_vars!
 
         service.execute
         status(200)
-      rescue => ex
+      rescue Gitlab::Changelog::Error => ex
         render_api_error!("Failed to generate the changelog: #{ex.message}", 500)
       end
     end
diff --git a/lib/gitlab/changelog/ast.rb b/lib/gitlab/changelog/ast.rb
new file mode 100644
index 0000000000000000000000000000000000000000..2c787d396f50ddf2ad45984141de285f1271690a
--- /dev/null
+++ b/lib/gitlab/changelog/ast.rb
@@ -0,0 +1,157 @@
+# frozen_string_literal: true
+
+module Gitlab
+  module Changelog
+    # AST nodes to evaluate when rendering a template.
+    #
+    # Evaluating an AST is done by walking over the nodes and calling
+    # `evaluate`. This method takes two arguments:
+    #
+    # 1. An instance of `EvalState`, used for tracking data such as the number
+    #    of nested loops.
+    # 2. An object used as the data for the current scope. This can be an Array,
+    #    Hash, String, or something else. It's up to the AST node to determine
+    #    what to do with it.
+    #
+    # While tree walking interpreters (such as implemented here) aren't usually
+    # the fastest type of interpreter, they are:
+    #
+    # 1. Fast enough for our use case
+    # 2. Easy to implement and maintain
+    #
+    # In addition, our AST interpreter doesn't allow for arbitrary code
+    # execution, unlike existing template engines such as Mustache
+    # (https://github.com/mustache/mustache/issues/244) or ERB.
+    #
+    # Our interpreter also takes care of limiting the number of nested loops.
+    # And unlike Liquid, our interpreter is much smaller and thus has a smaller
+    # attack surface. Liquid isn't without its share of issues, such as
+    # https://github.com/Shopify/liquid/pull/1071.
+    #
+    # We also evaluated using Handlebars using the project
+    # https://github.com/SmartBear/ruby-handlebars. Sadly, this implementation
+    # of Handlebars doesn't support control of whitespace
+    # (https://github.com/SmartBear/ruby-handlebars/issues/37), and the project
+    # didn't appear to be maintained that much.
+    #
+    # This doesn't mean these template engines aren't good, instead it means
+    # they won't work for our use case. For more information, refer to the
+    # comment https://gitlab.com/gitlab-org/gitlab/-/merge_requests/50063#note_469293322.
+    module AST
+      # An identifier in a selector.
+      Identifier = Struct.new(:name) do
+        def evaluate(state, data)
+          return data if name == 'it'
+
+          data[name] if data.is_a?(Hash)
+        end
+      end
+
+      # An integer used in a selector.
+      Integer = Struct.new(:value) do
+        def evaluate(state, data)
+          data[value] if data.is_a?(Array)
+        end
+      end
+
+      # A selector used for loading a value.
+      Selector = Struct.new(:steps) do
+        def evaluate(state, data)
+          steps.reduce(data) do |current, step|
+            break if current.nil?
+
+            step.evaluate(state, current)
+          end
+        end
+      end
+
+      # A tag used for displaying a value in the output.
+      Variable = Struct.new(:selector) do
+        def evaluate(state, data)
+          selector.evaluate(state, data).to_s
+        end
+      end
+
+      # A collection of zero or more expressions.
+      Expressions = Struct.new(:nodes) do
+        def evaluate(state, data)
+          nodes.map { |node| node.evaluate(state, data) }.join('')
+        end
+      end
+
+      # A single text node.
+      Text = Struct.new(:text) do
+        def evaluate(*)
+          text
+        end
+      end
+
+      # An `if` expression, with an optional `else` clause.
+      If = Struct.new(:condition, :true_body, :false_body) do
+        def evaluate(state, data)
+          result =
+            if truthy?(condition.evaluate(state, data))
+              true_body.evaluate(state, data)
+            elsif false_body
+              false_body.evaluate(state, data)
+            end
+
+          result.to_s
+        end
+
+        def truthy?(value)
+          # We treat empty collections and such as false, removing the need for
+          # some sort of `if length(x) > 0` expression.
+          value.respond_to?(:empty?) ? !value.empty? : !!value
+        end
+      end
+
+      # An `each` expression.
+      Each = Struct.new(:collection, :body) do
+        def evaluate(state, data)
+          values = collection.evaluate(state, data)
+
+          return '' unless values.respond_to?(:each)
+
+          # While unlikely to happen, it's possible users attempt to nest many
+          # loops in order to negatively impact the GitLab instance. To make
+          # this more difficult, we limit the number of nested loops a user can
+          # create.
+          state.enter_loop do
+            values.map { |value| body.evaluate(state, value) }.join('')
+          end
+        end
+      end
+
+      # A class for transforming a raw Parslet AST into a more structured/easier
+      # to work with AST.
+      #
+      # For more information about Parslet transformations, refer to the
+      # documentation at http://kschiess.github.io/parslet/transform.html.
+      class Transformer < Parslet::Transform
+        rule(ident: simple(:name)) { Identifier.new(name.to_s) }
+        rule(int: simple(:name)) { Integer.new(name.to_i) }
+        rule(text: simple(:text)) { Text.new(text.to_s) }
+        rule(exprs: subtree(:nodes)) { Expressions.new(nodes) }
+        rule(selector: sequence(:steps)) { Selector.new(steps) }
+        rule(selector: simple(:step)) { Selector.new([step]) }
+        rule(variable: simple(:selector)) { Variable.new(selector) }
+        rule(each: simple(:values), body: simple(:body)) do
+          Each.new(values, body)
+        end
+
+        rule(if: simple(:cond), true_body: simple(:true_body)) do
+          If.new(cond, true_body)
+        end
+
+        rule(
+          if: simple(:cond),
+          true_body: simple(:true_body),
+          false_body: simple(:false_body)
+        ) do
+          If.new(cond, true_body, false_body)
+        end
+      end
+    end
+  end
+end
diff --git a/lib/gitlab/changelog/committer.rb b/lib/gitlab/changelog/committer.rb
index 617017faa58d45b3e563f88de13218aed2d2338f..31661650eff86fa148f78db577afada2e6c39b4a 100644
--- a/lib/gitlab/changelog/committer.rb
+++ b/lib/gitlab/changelog/committer.rb
@@ -4,8 +4,6 @@ module Gitlab
   module Changelog
     # A class used for committing a release's changelog to a Git repository.
     class Committer
-      CommitError = Class.new(StandardError)
-
       def initialize(project, user)
         @project = project
         @user = user
@@ -25,7 +23,7 @@ def commit(release:, file:, branch:, message:)
         # When retrying, we need to reprocess the existing changelog from
         # scratch, otherwise we may end up throwing away changes. As such, all
         # the logic is contained within the retry block.
-        Retriable.retriable(on: CommitError) do
+        Retriable.retriable(on: Error) do
           commit = Gitlab::Git::Commit.last_for_path(
             @project.repository,
             branch,
@@ -57,7 +55,7 @@ def commit(release:, file:, branch:, message:)
 
           result = service.execute
 
-          raise CommitError.new(result[:message]) if result[:status] != :success
+          raise Error.new(result[:message]) if result[:status] != :success
         end
       end
 
diff --git a/lib/gitlab/changelog/config.rb b/lib/gitlab/changelog/config.rb
index 3f06b612687efb4672cbb48436ddbc7027722290..105050936cedebd850db589e5ed8c2a73efcd0fa 100644
--- a/lib/gitlab/changelog/config.rb
+++ b/lib/gitlab/changelog/config.rb
@@ -4,8 +4,6 @@ module Gitlab
   module Changelog
     # Configuration settings used when generating changelogs.
     class Config
-      ConfigError = Class.new(StandardError)
-
       # When rendering changelog entries, authors are not included.
       AUTHORS_NONE = 'none'
 
@@ -37,17 +35,14 @@ def self.from_hash(project, hash)
         end
 
         if (template = hash['template'])
-          # We use the full namespace here (and further down) as otherwise Rails
-          # may use the wrong constant when autoloading is used.
-          config.template =
-            ::Gitlab::Changelog::Template::Compiler.new.compile(template)
+          config.template = Parser.new.parse_and_transform(template)
         end
 
         if (categories = hash['categories'])
           if categories.is_a?(Hash)
             config.categories = categories
           else
-            raise ConfigError, 'The "categories" configuration key must be a Hash'
+            raise Error, 'The "categories" configuration key must be a Hash'
           end
         end
 
@@ -57,8 +52,7 @@ def self.from_hash(project, hash)
       def initialize(project)
         @project = project
         @date_format = DEFAULT_DATE_FORMAT
-        @template =
-          ::Gitlab::Changelog::Template::Compiler.new.compile(DEFAULT_TEMPLATE)
+        @template = Parser.new.parse_and_transform(DEFAULT_TEMPLATE)
         @categories = {}
       end
 
diff --git a/lib/gitlab/changelog/error.rb b/lib/gitlab/changelog/error.rb
new file mode 100644
index 0000000000000000000000000000000000000000..0bd886fbdb7fbf71458251badc873ef6e80aa58e
--- /dev/null
+++ b/lib/gitlab/changelog/error.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+module Gitlab
+  module Changelog
+    # An error raised when a changelog couldn't be generated.
+    Error = Class.new(StandardError)
+  end
+end
diff --git a/lib/gitlab/changelog/eval_state.rb b/lib/gitlab/changelog/eval_state.rb
new file mode 100644
index 0000000000000000000000000000000000000000..a0439df60cff8ac3f661fcfdb48c29c55e2805c4
--- /dev/null
+++ b/lib/gitlab/changelog/eval_state.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+module Gitlab
+  module Changelog
+    # A class for tracking state when evaluating a template
+    class EvalState
+      MAX_LOOPS = 4
+
+      def initialize
+        @loops = 0
+      end
+
+      def enter_loop
+        if @loops == MAX_LOOPS
+          raise Error, "You can only nest up to #{MAX_LOOPS} loops"
+        end
+
+        @loops += 1
+        retval = yield
+        @loops -= 1
+
+        retval
+      end
+    end
+  end
+end
diff --git a/lib/gitlab/changelog/parser.rb b/lib/gitlab/changelog/parser.rb
new file mode 100644
index 0000000000000000000000000000000000000000..a4c8da283cd0bf6b25071d5eaba8c7906969cd4b
--- /dev/null
+++ b/lib/gitlab/changelog/parser.rb
@@ -0,0 +1,176 @@
+# frozen_string_literal: true
+
+module Gitlab
+  module Changelog
+    # A parser for the template syntax used for generating changelogs.
+    #
+    # As a quick primer on the template syntax, a basic template looks like
+    # this:
+    #
+    #     {% each users %}
+    #     Name: {{name}}
+    #     Age: {{age}}
+    #
+    #     {% if birthday %}
+    #     This user is celebrating their birthday today! Yay!
+    #     {% end %}
+    #     {% end %}
+    #
+    # For more information, refer to the Parslet documentation found at
+    # http://kschiess.github.io/parslet/.
+    class Parser < Parslet::Parser
+      root(:exprs)
+
+      rule(:exprs) do
+        (
+          variable | if_expr | each_expr | escaped | text | newline
+        ).repeat.as(:exprs)
+      end
+
+      rule(:space) { match('[ \\t]') }
+      rule(:whitespace) { match('\s').repeat }
+      rule(:lf) { str("\n") }
+      rule(:newline) { lf.as(:text) }
+
+      # Escaped newlines are ignored, allowing the user to control the
+      # whitespace in the output. All other escape sequences are treated as
+      # literal text.
+      #
+      # For example, this:
+      #
+      #     foo \
+      #     bar
+      #
+      # Is parsed into this:
+      #
+      #     foo bar
+      rule(:escaped) do
+        backslash = str('\\')
+
+        (backslash >> lf).ignore | (backslash >> chars).as(:text)
+      end
+
+      # A sequence of regular characters, with the exception of newlines and
+      # escaped newlines.
+      rule(:chars) do
+        char = match("[^{\\\\\n]")
+
+        # The rules here are such that we do treat single curly braces or
+        # non-opening tags (e.g. `{foo}`) as text, but not opening tags
+        # themselves (e.g. `{{`).
+        (
+          char.repeat(1) | curly_open >> (curly_open | percent).absent?
+        ).repeat(1)
+      end
+
+      rule(:text) { chars.as(:text) }
+
+      # An integer, limited to 10 digits (= a 32 bits integer).
+      #
+      # The size is limited to prevents users from creating integers that are
+      # too large, as this may result in runtime errors.
+      rule(:integer) { match('\d').repeat(1, 10).as(:int) }
+
+      # An identifier to look up in a data structure.
+      #
+      # We only support simple ASCII identifiers as we simply don't have a need
+      # for more complex identifiers (e.g. those containing multibyte
+      # characters).
+      rule(:ident) { match('[a-zA-Z_]').repeat(1).as(:ident) }
+
+      # A selector is used for reading a value, consisting of one or more
+      # "steps".
+      #
+      # Examples:
+      #
+      #     name
+      #     users.0.name
+      #     0
+      #     it
+      rule(:selector) do
+        step = ident | integer
+
+        whitespace >>
+          (step >> (str('.') >> step).repeat).as(:selector) >>
+          whitespace
+      end
+
+      rule(:curly_open) { str('{') }
+      rule(:curly_close) { str('}') }
+      rule(:percent) { str('%') }
+
+      # A variable tag.
+      #
+      # Examples:
+      #
+      #     {{name}}
+      #     {{users.0.name}}
+      rule(:variable) do
+        curly_open.repeat(2) >> selector.as(:variable) >> curly_close.repeat(2)
+      end
+
+      rule(:expr_open) { curly_open >> percent >> whitespace }
+      rule(:expr_close) do
+        # Since whitespace control is important (as Markdown is whitespace
+        # sensitive), we default to stripping a newline that follows a %} tag.
+        # This is less annoying compared to having to opt-in to this behaviour.
+        whitespace >> percent >> curly_close >> lf.maybe.ignore
+      end
+
+      rule(:end_tag) { expr_open >> str('end') >> expr_close }
+
+      # An `if` expression, with an optional `else` clause.
+      #
+      # Examples:
+      #
+      #     {% if foo %}
+      #     yes
+      #     {% end %}
+      #
+      #     {% if foo %}
+      #     yes
+      #     {% else %}
+      #     no
+      #     {% end %}
+      rule(:if_expr) do
+        else_tag =
+          expr_open >> str('else') >> expr_close >> exprs.as(:false_body)
+
+        expr_open >>
+          str('if') >>
+          space.repeat(1) >>
+          selector.as(:if) >>
+          expr_close >>
+          exprs.as(:true_body) >>
+          else_tag.maybe >>
+          end_tag
+      end
+
+      # An `each` expression, used for iterating over collections.
+      #
+      # Example:
+      #
+      #     {% each users %}
+      #     * {{name}}
+      #     {% end %}
+      rule(:each_expr) do
+        expr_open >>
+          str('each') >>
+          space.repeat(1) >>
+          selector.as(:each) >>
+          expr_close >>
+          exprs.as(:body) >>
+          end_tag
+      end
+
+      def parse_and_transform(input)
+        AST::Transformer.new.apply(parse(input))
+      rescue Parslet::ParseFailed => ex
+        # We raise a custom error so it's easier to catch different changelog
+        # related errors. In addition, this ensures the caller of this method
+        # doesn't depend on a Parslet specific error class.
+        raise Error.new("Failed to parse the template: #{ex.message}")
+      end
+    end
+  end
+end
diff --git a/lib/gitlab/changelog/release.rb b/lib/gitlab/changelog/release.rb
index 4c78eb5080c7ce673945cf281ede3caa6625946a..f2a01c2b0dcff42e78c78353b8209ad115ff9ea4 100644
--- a/lib/gitlab/changelog/release.rb
+++ b/lib/gitlab/changelog/release.rb
@@ -54,14 +54,16 @@ def add_entry(
       end
 
       def to_markdown
+        state = EvalState.new
+        data = { 'categories' => entries_for_template }
+
         # While not critical, we would like release sections to be separated by
         # an empty line in the changelog; ensuring it's readable even in its
         # raw form.
         #
-        # Since it can be a bit tricky to get this right using Liquid, we
+        # Since it can be a bit tricky to get this right in a template, we
         # enforce an empty line separator ourselves.
-        markdown =
-          @config.template.render('categories' => entries_for_template).strip
+        markdown = @config.template.evaluate(state, data).strip
 
         # The release header can't be changed using the Liquid template, as we
         # need this to be in a known format. Without this restriction, we won't
@@ -80,14 +82,20 @@ def release_date
       end
 
       def entries_for_template
-        @entries.map do |category, entries|
-          {
+        rows = []
+
+        @entries.each do |category, entries|
+          next if entries.empty?
+
+          rows << {
             'title' => category,
             'count' => entries.length,
             'single_change' => entries.length == 1,
             'entries' => entries
           }
         end
+
+        rows
       end
     end
   end
diff --git a/lib/gitlab/changelog/template.tpl b/lib/gitlab/changelog/template.tpl
index 838b7080f6841140c8dea81b2ebb031f29267682..584939dff51b3bccfd138c62aa02ab9242a03d0f 100644
--- a/lib/gitlab/changelog/template.tpl
+++ b/lib/gitlab/changelog/template.tpl
@@ -6,6 +6,7 @@
 - [{{ title }}]({{ commit.reference }})\
 {% if author.contributor %} by {{ author.reference }}{% end %}\
 {% if merge_request %} ([merge request]({{ merge_request.reference }})){% end %}
+
 {% end %}
 
 {% end %}
diff --git a/lib/gitlab/changelog/template/compiler.rb b/lib/gitlab/changelog/template/compiler.rb
deleted file mode 100644
index fa7724aa2dacbbe81d3404ce8d6285cdfc36066b..0000000000000000000000000000000000000000
--- a/lib/gitlab/changelog/template/compiler.rb
+++ /dev/null
@@ -1,154 +0,0 @@
-# frozen_string_literal: true
-
-module Gitlab
-  module Changelog
-    module Template
-      # Compiler is used for turning a minimal user templating language into an
-      # ERB template, without giving the user access to run arbitrary code.
-      #
-      # The template syntax is deliberately made as minimal as possible, and
-      # only supports the following:
-      #
-      # * Printing a value
-      # * Iterating over collections
-      # * if/else
-      #
-      # The syntax looks as follows:
-      #
-      #     {% each users %}
-      #
-      #     Name: {{user}}
-      #     Likes cats: {% if likes_cats %}yes{% else %}no{% end %}
-      #
-      #     {% end %}
-      #
-      # Newlines can be escaped by ending a line with a backslash. So this:
-      #
-      #     foo \
-      #     bar
-      #
-      # Is the same as this:
-      #
-      #     foo bar
-      #
-      # Templates are compiled into ERB templates, while taking care to make
-      # sure the user can't run arbitrary code. By using ERB we can let it do
-      # the heavy lifting of rendering data; all we need to provide is a
-      # translation layer.
-      #
-      # # Security
-      #
-      # The template syntax this compiler exposes is safe to be used by
-      # untrusted users. Not only are they unable to run arbitrary code, the
-      # compiler also enforces a limit on the integer sizes and the number of
-      # nested loops. ERB tags added by the user are also disabled.
-      class Compiler
-        # A pattern to match a single integer, with an upper size limit.
-        #
-        # We enforce a limit of 10 digits (= a 32 bits integer) so users can't
-        # trigger the allocation of infinitely large bignums, or trigger
-        # RangeError errors when using such integers to access an array value.
-        INTEGER = /^\d{1,10}$/.freeze
-
-        # The name/path of a variable, such as `user.address.city`.
-        #
-        # It's important that this regular expression _doesn't_ allow for
-        # anything but letters, numbers, and underscores, otherwise a user may
-        # use those to "escape" our template and run arbirtary Ruby code. For
-        # example, take this variable:
-        #
-        #     {{') ::Kernel.exit #'}}
-        #
-        # This would then be compiled into:
-        #
-        #     <%= read(variables, '') ::Kernel.exit #'') %>
-        #
-        # Restricting the allowed characters makes this impossible.
-        VAR_NAME = /([\w\.]+)/.freeze
-
-        # A variable tag, such as `{{username}}`.
-        VAR = /{{ \s* #{VAR_NAME} \s* }}/x.freeze
-
-        # The opening tag for a statement.
-        STM_START = /{% \s*/x.freeze
-
-        # The closing tag for a statement.
-        STM_END = /\s* %}/x.freeze
-
-        # A regular `end` closing tag.
-        NORMAL_END = /#{STM_START} end #{STM_END}/x.freeze
-
-        # An `end` closing tag on its own line, without any non-whitespace
-        # preceding or following it.
-        #
-        # These tags need some special care to make it easier to control
-        # whitespace.
-        LONELY_END = /^\s*#{NORMAL_END}\s$/x.freeze
-
-        # An `else` tag.
-        ELSE = /#{STM_START} else #{STM_END}/x.freeze
-
-        # The start of an `each` tag.
-        EACH = /#{STM_START} each \s+ #{VAR_NAME} #{STM_END}/x.freeze
-
-        # The start of an `if` tag.
-        IF = /#{STM_START} if \s+ #{VAR_NAME} #{STM_END}/x.freeze
-
-        # The pattern to use for escaping newlines.
-        ESCAPED_NEWLINE = /\\\n$/.freeze
-
-        # The start tag for ERB tags. These tags will be escaped, preventing
-        # users from using ERB directly.
-        ERB_START_TAG = /<\\?\s*\\?\s*%/.freeze
-
-        def compile(template)
-          transformed_lines = ['<% it = variables %>']
-
-          # ERB tags must be stripped here, otherwise a user may introduce ERB
-          # tags by making clever use of whitespace. See
-          # https://gitlab.com/gitlab-org/gitlab/-/issues/300224 for more
-          # information.
-          template = template.gsub(ERB_START_TAG, '<%%')
-
-          template.each_line { |line| transformed_lines << transform(line) }
-
-          # We use the full namespace here as otherwise Rails may use the wrong
-          # constant when autoloading is used.
-          ::Gitlab::Changelog::Template::Template.new(transformed_lines.join)
-        end
-
-        def transform(line)
-          line.gsub!(ESCAPED_NEWLINE, '')
-
-          # This replacement ensures that "end" blocks on their own lines
-          # don't add extra newlines. Using an ERB -%> tag sadly swallows too
-          # many newlines.
-          line.gsub!(LONELY_END, '<% end %>')
-          line.gsub!(NORMAL_END, '<% end %>')
-          line.gsub!(ELSE, '<% else -%>')
-
-          line.gsub!(EACH) do
-            # No, `it; variables` isn't a syntax error. Using `;` marks
-            # `variables` as block-local, making it possible to re-assign it
-            # without affecting outer definitions of this variable. We use
-            # this to scope template variables to the right input Hash.
-            "<% each(#{read_path(Regexp.last_match(1))}) do |it; variables| -%><% variables = it -%>"
-          end
-
-          line.gsub!(IF) { "<% if truthy?(#{read_path(Regexp.last_match(1))}) -%>" }
-          line.gsub!(VAR) { "<%= #{read_path(Regexp.last_match(1))} %>" }
-          line
-        end
-
-        def read_path(path)
-          return path if path == 'it'
-
-          args = path.split('.')
-          args.map! { |arg| arg.match?(INTEGER) ? "#{arg}" : "'#{arg}'" }
-
-          "read(variables, #{args.join(', ')})"
-        end
-      end
-    end
-  end
-end
diff --git a/lib/gitlab/changelog/template/context.rb b/lib/gitlab/changelog/template/context.rb
deleted file mode 100644
index 8a0796d767e2b14ff8d327c5a7ff1acc3a448eb5..0000000000000000000000000000000000000000
--- a/lib/gitlab/changelog/template/context.rb
+++ /dev/null
@@ -1,70 +0,0 @@
-# frozen_string_literal: true
-
-module Gitlab
-  module Changelog
-    module Template
-      # Context is used to provide a binding/context to ERB templates used for
-      # rendering changelogs.
-      #
-      # This class extends BasicObject so that we only expose the bare minimum
-      # needed to render the ERB template.
-      class Context < BasicObject
-        MAX_NESTED_LOOPS = 4
-
-        def initialize(variables)
-          @variables = variables
-          @loop_nesting = 0
-        end
-
-        def get_binding
-          ::Kernel.binding
-        end
-
-        def each(value, &block)
-          max = MAX_NESTED_LOOPS
-
-          if @loop_nesting == max
-            ::Kernel.raise(
-              ::Template::TemplateError.new("You can only nest up to #{max} loops")
-            )
-          end
-
-          @loop_nesting += 1
-          result = value.each(&block) if value.respond_to?(:each)
-          @loop_nesting -= 1
-
-          result
-        end
-
-        # rubocop: disable Style/TrivialAccessors
-        def variables
-          @variables
-        end
-        # rubocop: enable Style/TrivialAccessors
-
-        def read(source, *steps)
-          current = source
-
-          steps.each do |step|
-            case current
-            when ::Hash
-              current = current[step]
-            when ::Array
-              return '' unless step.is_a?(::Integer)
-
-              current = current[step]
-            else
-              break
-            end
-          end
-
-          current
-        end
-
-        def truthy?(value)
-          value.respond_to?(:any?) ? value.any? : !!value
-        end
-      end
-    end
-  end
-end
diff --git a/lib/gitlab/changelog/template/template.rb b/lib/gitlab/changelog/template/template.rb
deleted file mode 100644
index 0ff2852d6d486db0acb998373194a90c9f94a9be..0000000000000000000000000000000000000000
--- a/lib/gitlab/changelog/template/template.rb
+++ /dev/null
@@ -1,29 +0,0 @@
-# frozen_string_literal: true
-
-module Gitlab
-  module Changelog
-    module Template
-      # A wrapper around an ERB template user for rendering changelogs.
-      class Template
-        TemplateError = Class.new(StandardError)
-
-        def initialize(erb)
-          # Don't change the trim mode, as this may require changes to the
-          # regular expressions used to turn the template syntax into ERB
-          # tags.
-          @erb = ERB.new(erb, trim_mode: '-')
-        end
-
-        def render(data)
-          context = Context.new(data).get_binding
-
-          # ERB produces a SyntaxError when processing templates, as it
-          # internally uses eval() for this.
-          @erb.result(context)
-        rescue SyntaxError
-          raise TemplateError.new("The template's syntax is invalid")
-        end
-      end
-    end
-  end
-end
diff --git a/spec/lib/gitlab/changelog/ast_spec.rb b/spec/lib/gitlab/changelog/ast_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..fa15ac979fe690a9afcf7c246a89a88586bab71e
--- /dev/null
+++ b/spec/lib/gitlab/changelog/ast_spec.rb
@@ -0,0 +1,246 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Changelog::AST::Identifier do
+  let(:state) { Gitlab::Changelog::EvalState.new }
+
+  describe '#evaluate' do
+    it 'evaluates a selector' do
+      data = { 'number' => 10 }
+
+      expect(described_class.new('number').evaluate(state, data)).to eq(10)
+    end
+
+    it 'returns nil if the key is not set' do
+      expect(described_class.new('number').evaluate(state, {})).to be_nil
+    end
+
+    it 'returns nil if the input is not a Hash' do
+      expect(described_class.new('number').evaluate(state, 45)).to be_nil
+    end
+
+    it 'returns the current data when using the special identifier "it"' do
+      expect(described_class.new('it').evaluate(state, 45)).to eq(45)
+    end
+  end
+end
+
+RSpec.describe Gitlab::Changelog::AST::Integer do
+  let(:state) { Gitlab::Changelog::EvalState.new }
+
+  describe '#evaluate' do
+    it 'evaluates a selector' do
+      expect(described_class.new(0).evaluate(state, [10])).to eq(10)
+    end
+
+    it 'returns nil if the index is not set' do
+      expect(described_class.new(1).evaluate(state, [10])).to be_nil
+    end
+
+    it 'returns nil if the input is not an Array' do
+      expect(described_class.new(0).evaluate(state, {})).to be_nil
+    end
+  end
+end
+
+RSpec.describe Gitlab::Changelog::AST::Selector do
+  let(:state) { Gitlab::Changelog::EvalState.new }
+  let(:data) { { 'numbers' => [10] } }
+
+  describe '#evaluate' do
+    it 'evaluates a selector' do
+      ident = Gitlab::Changelog::AST::Identifier.new('numbers')
+      int = Gitlab::Changelog::AST::Integer.new(0)
+
+      expect(described_class.new([ident, int]).evaluate(state, data)).to eq(10)
+    end
+
+    it 'evaluates a selector that returns nil' do
+      int = Gitlab::Changelog::AST::Integer.new(0)
+
+      expect(described_class.new([int]).evaluate(state, data)).to be_nil
+    end
+  end
+end
+
+RSpec.describe Gitlab::Changelog::AST::Variable do
+  let(:state) { Gitlab::Changelog::EvalState.new }
+  let(:data) { { 'numbers' => [10] } }
+
+  describe '#evaluate' do
+    it 'evaluates a variable' do
+      node = Gitlab::Changelog::Parser
+        .new
+        .parse_and_transform('{{numbers.0}}')
+        .nodes[0]
+
+      expect(node.evaluate(state, data)).to eq('10')
+    end
+
+    it 'evaluates an undefined variable' do
+      node =
+        Gitlab::Changelog::Parser.new.parse_and_transform('{{foobar}}').nodes[0]
+
+      expect(node.evaluate(state, data)).to eq('')
+    end
+
+    it 'evaluates the special variable "it"' do
+      node =
+        Gitlab::Changelog::Parser.new.parse_and_transform('{{it}}').nodes[0]
+
+      expect(node.evaluate(state, data)).to eq(data.to_s)
+    end
+  end
+end
+
+RSpec.describe Gitlab::Changelog::AST::Expressions do
+  let(:state) { Gitlab::Changelog::EvalState.new }
+
+  describe '#evaluate' do
+    it 'evaluates all expressions' do
+      node = Gitlab::Changelog::Parser
+        .new
+        .parse_and_transform('{{number}}foo')
+
+      expect(node.evaluate(state, { 'number' => 10 })).to eq('10foo')
+    end
+  end
+end
+
+RSpec.describe Gitlab::Changelog::AST::Text do
+  let(:state) { Gitlab::Changelog::EvalState.new }
+
+  describe '#evaluate' do
+    it 'returns the text' do
+      expect(described_class.new('foo').evaluate(state, {})).to eq('foo')
+    end
+  end
+end
+
+RSpec.describe Gitlab::Changelog::AST::If do
+  let(:state) { Gitlab::Changelog::EvalState.new }
+
+  describe '#evaluate' do
+    it 'evaluates a truthy if expression without an else clause' do
+      node = Gitlab::Changelog::Parser
+        .new
+        .parse_and_transform('{% if thing %}foo{% end %}')
+        .nodes[0]
+
+      expect(node.evaluate(state, { 'thing' => true })).to eq('foo')
+    end
+
+    it 'evaluates a falsy if expression without an else clause' do
+      node = Gitlab::Changelog::Parser
+        .new
+        .parse_and_transform('{% if thing %}foo{% end %}')
+        .nodes[0]
+
+      expect(node.evaluate(state, { 'thing' => false })).to eq('')
+    end
+
+    it 'evaluates a falsy if expression with an else clause' do
+      node = Gitlab::Changelog::Parser
+        .new
+        .parse_and_transform('{% if thing %}foo{% else %}bar{% end %}')
+        .nodes[0]
+
+      expect(node.evaluate(state, { 'thing' => false })).to eq('bar')
+    end
+  end
+
+  describe '#truthy?' do
+    it 'returns true for a non-empty String' do
+      expect(described_class.new.truthy?('foo')).to eq(true)
+    end
+
+    it 'returns true for a non-empty Array' do
+      expect(described_class.new.truthy?([10])).to eq(true)
+    end
+
+    it 'returns true for a Boolean true' do
+      expect(described_class.new.truthy?(true)).to eq(true)
+    end
+
+    it 'returns false for an empty String' do
+      expect(described_class.new.truthy?('')).to eq(false)
+    end
+
+    it 'returns true for an empty Array' do
+      expect(described_class.new.truthy?([])).to eq(false)
+    end
+
+    it 'returns false for a Boolean false' do
+      expect(described_class.new.truthy?(false)).to eq(false)
+    end
+  end
+end
+
+RSpec.describe Gitlab::Changelog::AST::Each do
+  let(:state) { Gitlab::Changelog::EvalState.new }
+
+  describe '#evaluate' do
+    it 'evaluates the expression' do
+      data = { 'animals' => [{ 'name' => 'Cat' }, { 'name' => 'Dog' }] }
+      node = Gitlab::Changelog::Parser
+        .new
+        .parse_and_transform('{% each animals %}{{name}}{% end %}')
+        .nodes[0]
+
+      expect(node.evaluate(state, data)).to eq('CatDog')
+    end
+
+    it 'returns an empty string when the input is not a collection' do
+      data = { 'animals' => 10 }
+      node = Gitlab::Changelog::Parser
+        .new
+        .parse_and_transform('{% each animals %}{{name}}{% end %}')
+        .nodes[0]
+
+      expect(node.evaluate(state, data)).to eq('')
+    end
+
+    it 'disallows too many nested loops' do
+      data = {
+        'foo' => [
+          {
+            'bar' => [
+              {
+                'baz' => [
+                  {
+                    'quix' => [
+                      {
+                        'foo' => [{ 'name' => 'Alice' }]
+                      }
+                    ]
+                  }
+                ]
+              }
+            ]
+          }
+        ]
+      }
+
+      template = <<~TPL
+        {% each foo %}
+          {% each bar %}
+            {% each baz %}
+              {% each quix %}
+                {% each foo %}
+                  {{name}}
+                {% end %}
+              {% end %}
+            {% end %}
+          {% end %}
+        {% end %}
+      TPL
+
+      node =
+        Gitlab::Changelog::Parser.new.parse_and_transform(template).nodes[0]
+
+      expect { node.evaluate(state, data) }
+        .to raise_error(Gitlab::Changelog::Error)
+    end
+  end
+end
diff --git a/spec/lib/gitlab/changelog/committer_spec.rb b/spec/lib/gitlab/changelog/committer_spec.rb
index f0d6bc2b6b503f163df07c8c459496d685f62987..1e04fe346cb9adbbe64ca86481a22e052d84758e 100644
--- a/spec/lib/gitlab/changelog/committer_spec.rb
+++ b/spec/lib/gitlab/changelog/committer_spec.rb
@@ -88,7 +88,7 @@
     end
 
     context "when the changelog changes before saving the changes" do
-      it 'raises a CommitError' do
+      it 'raises a Error' do
         release1 = Gitlab::Changelog::Release
           .new(version: '1.0.0', date: Time.utc(2020, 1, 1), config: config)
 
@@ -121,7 +121,7 @@
             branch: 'master',
             message: 'Test commit'
           )
-        end.to raise_error(described_class::CommitError)
+        end.to raise_error(Gitlab::Changelog::Error)
       end
     end
   end
diff --git a/spec/lib/gitlab/changelog/config_spec.rb b/spec/lib/gitlab/changelog/config_spec.rb
index adf82fa3ac201aa26f52f0050a213c45d191af85..51988acf3d13b011bcdef08234c0f0e6baa87e66 100644
--- a/spec/lib/gitlab/changelog/config_spec.rb
+++ b/spec/lib/gitlab/changelog/config_spec.rb
@@ -41,13 +41,15 @@
       )
 
       expect(config.date_format).to eq('foo')
-      expect(config.template).to be_instance_of(Gitlab::Changelog::Template::Template)
+      expect(config.template)
+        .to be_instance_of(Gitlab::Changelog::AST::Expressions)
+
       expect(config.categories).to eq({ 'foo' => 'bar' })
     end
 
-    it 'raises ConfigError when the categories are not a Hash' do
+    it 'raises Error when the categories are not a Hash' do
       expect { described_class.from_hash(project, 'categories' => 10) }
-        .to raise_error(described_class::ConfigError)
+        .to raise_error(Gitlab::Changelog::Error)
     end
   end
 
diff --git a/spec/lib/gitlab/changelog/parser_spec.rb b/spec/lib/gitlab/changelog/parser_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..1d353f5eb35c6c8e3222b3fe4d0a1472e9c5f72e
--- /dev/null
+++ b/spec/lib/gitlab/changelog/parser_spec.rb
@@ -0,0 +1,78 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Changelog::Parser do
+  let(:parser) { described_class.new }
+
+  describe '#root' do
+    it 'parses an empty template' do
+      expect(parser.root).to parse('')
+    end
+
+    it 'parses a variable with a single identifier step' do
+      expect(parser.root).to parse('{{foo}}')
+    end
+
+    it 'parses a variable with a single integer step' do
+      expect(parser.root).to parse('{{0}}')
+    end
+
+    it 'parses a variable with multiple selector steps' do
+      expect(parser.root).to parse('{{foo.bar}}')
+    end
+
+    it 'parses a variable with an integer selector step' do
+      expect(parser.root).to parse('{{foo.bar.0}}')
+    end
+
+    it 'parses the special "it" variable' do
+      expect(parser.root).to parse('{{it}}')
+    end
+
+    it 'parses a text node' do
+      expect(parser.root).to parse('foo')
+    end
+
+    it 'parses an if expression' do
+      expect(parser.root).to parse('{% if foo %}bar{% end %}')
+    end
+
+    it 'parses an if-else expression' do
+      expect(parser.root).to parse('{% if foo %}bar{% else %}baz{% end %}')
+    end
+
+    it 'parses an each expression' do
+      expect(parser.root).to parse('{% each foo %}foo{% end %}')
+    end
+
+    it 'parses an escaped newline' do
+      expect(parser.root).to parse("foo\\\nbar")
+    end
+
+    it 'parses a regular newline' do
+      expect(parser.root).to parse("foo\nbar")
+    end
+
+    it 'parses the default changelog template' do
+      expect(parser.root).to parse(Gitlab::Changelog::Config::DEFAULT_TEMPLATE)
+    end
+
+    it 'raises an error when parsing an integer selector that is too large' do
+      expect(parser.root).not_to parse('{{100000000000}}')
+    end
+  end
+
+  describe '#parse_and_transform' do
+    it 'parses and transforms a template' do
+      node = parser.parse_and_transform('foo')
+
+      expect(node).to be_instance_of(Gitlab::Changelog::AST::Expressions)
+    end
+
+    it 'raises parsing errors using a custom error class' do
+      expect { parser.parse_and_transform('{% each') }
+        .to raise_error(Gitlab::Changelog::Error)
+    end
+  end
+end
diff --git a/spec/lib/gitlab/changelog/release_spec.rb b/spec/lib/gitlab/changelog/release_spec.rb
index 50a23d23299300c6b7c2930ce141c5586a436e4e..f95244d67500b5aa2ac5f6be4f637345e4e9d253 100644
--- a/spec/lib/gitlab/changelog/release_spec.rb
+++ b/spec/lib/gitlab/changelog/release_spec.rb
@@ -93,6 +93,28 @@
         OUT
       end
     end
+
+    context 'when a category has no entries' do
+      it "isn't included in the output" do
+        config.categories['kittens'] = 'Kittens'
+        config.categories['fixed'] = 'Bug fixes'
+
+        release.add_entry(
+          title: 'Entry title',
+          commit: commit,
+          category: 'fixed'
+        )
+
+        expect(release.to_markdown).to eq(<<~OUT)
+          ## 1.0.0 (2021-01-05)
+
+          ### Bug fixes (1 change)
+
+          - [Entry title](#{commit.to_reference(full: true)})
+
+        OUT
+      end
+    end
   end
 
   describe '#header_start_position' do
diff --git a/spec/lib/gitlab/changelog/template/compiler_spec.rb b/spec/lib/gitlab/changelog/template/compiler_spec.rb
deleted file mode 100644
index 8b09bc90529dc30f3c3cf4c538d39281348d81ca..0000000000000000000000000000000000000000
--- a/spec/lib/gitlab/changelog/template/compiler_spec.rb
+++ /dev/null
@@ -1,136 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Gitlab::Changelog::Template::Compiler do
-  def compile(template, data = {})
-    Gitlab::Changelog::Template::Compiler.new.compile(template).render(data)
-  end
-
-  describe '#compile' do
-    it 'compiles an empty template' do
-      expect(compile('')).to eq('')
-    end
-
-    it 'compiles a template with an undefined variable' do
-      expect(compile('{{number}}')).to eq('')
-    end
-
-    it 'compiles a template with a defined variable' do
-      expect(compile('{{number}}', 'number' => 42)).to eq('42')
-    end
-
-    it 'compiles a template with the special "it" variable' do
-      expect(compile('{{it}}', 'values' => 10)).to eq({ 'values' => 10 }.to_s)
-    end
-
-    it 'compiles a template containing an if statement' do
-      expect(compile('{% if foo %}yes{% end %}', 'foo' => true)).to eq('yes')
-    end
-
-    it 'compiles a template containing an if/else statement' do
-      expect(compile('{% if foo %}yes{% else %}no{% end %}', 'foo' => false))
-        .to eq('no')
-    end
-
-    it 'compiles a template that iterates over an Array' do
-      expect(compile('{% each numbers %}{{it}}{% end %}', 'numbers' => [1, 2, 3]))
-        .to eq('123')
-    end
-
-    it 'compiles a template that iterates over a Hash' do
-      output = compile(
-        '{% each pairs %}{{0}}={{1}}{% end %}',
-        'pairs' => { 'key' => 'value' }
-      )
-
-      expect(output).to eq('key=value')
-    end
-
-    it 'compiles a template that iterates over a Hash of Arrays' do
-      output = compile(
-        '{% each values %}{{key}}{% end %}',
-        'values' => [{ 'key' => 'value' }]
-      )
-
-      expect(output).to eq('value')
-    end
-
-    it 'compiles a template with a variable path' do
-      output = compile('{{foo.bar}}', 'foo' => { 'bar' => 10 })
-
-      expect(output).to eq('10')
-    end
-
-    it 'compiles a template with a variable path that uses an Array index' do
-      output = compile('{{foo.values.1}}', 'foo' => { 'values' => [10, 20] })
-
-      expect(output).to eq('20')
-    end
-
-    it 'compiles a template with a variable path that uses a Hash and a numeric index' do
-      output = compile('{{foo.1}}', 'foo' => { 'key' => 'value' })
-
-      expect(output).to eq('')
-    end
-
-    it 'compiles a template with a variable path that uses an Array and a String based index' do
-      output = compile('{{foo.numbers.bla}}', 'foo' => { 'numbers' => [10, 20] })
-
-      expect(output).to eq('')
-    end
-
-    it 'ignores ERB tags provided by the user' do
-      input = '<% exit %> <%= exit %> <%= foo -%>'
-
-      expect(compile(input)).to eq(input)
-    end
-
-    it 'removes newlines introduced by end statements on their own lines' do
-      output = compile(<<~TPL, 'foo' => true)
-        {% if foo %}
-        foo
-        {% end %}
-      TPL
-
-      expect(output).to eq("foo\n")
-    end
-
-    it 'supports escaping of trailing newlines' do
-      output = compile(<<~TPL)
-        foo \
-        bar\
-        baz
-      TPL
-
-      expect(output).to eq("foo barbaz\n")
-    end
-
-    # rubocop: disable Lint/InterpolationCheck
-    it 'ignores embedded Ruby expressions' do
-      input = '#{exit}'
-
-      expect(compile(input)).to eq(input)
-    end
-    # rubocop: enable Lint/InterpolationCheck
-
-    it 'ignores ERB tags inside variable tags' do
-      input = '{{<%= exit %>}}'
-
-      expect(compile(input)).to eq(input)
-    end
-
-    it 'ignores malicious code that tries to escape a variable' do
-      input = "{{') ::Kernel.exit # '}}"
-
-      expect(compile(input)).to eq(input)
-    end
-
-    it 'ignores malicious code that makes use of whitespace' do
-      input = "x<\\\n%::Kernel.system(\"id\")%>"
-
-      expect(Kernel).not_to receive(:system).with('id')
-      expect(compile(input)).to eq('x<%::Kernel.system("id")%>')
-    end
-  end
-end
diff --git a/spec/requests/api/repositories_spec.rb b/spec/requests/api/repositories_spec.rb
index a05f173070865f96ba402bbaff283fe0c2cc7a6c..e4945397fe38dacb272b266f6d501e92b7feaaf9 100644
--- a/spec/requests/api/repositories_spec.rb
+++ b/spec/requests/api/repositories_spec.rb
@@ -672,7 +672,7 @@
 
         allow(spy)
           .to receive(:execute)
-          .and_raise(Gitlab::Changelog::Committer::CommitError.new('oops'))
+          .and_raise(Gitlab::Changelog::Error.new('oops'))
 
         post(
           api("/projects/#{project.id}/repository/changelog", user),
diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb
index cd3818b256de374f71f2b3a61200b5cd5d2958d8..64c1479a412aecea7c15fed6ea8d51b612b99802 100644
--- a/spec/spec_helper.rb
+++ b/spec/spec_helper.rb
@@ -34,6 +34,7 @@
 require 'shoulda/matchers'
 require 'test_prof/recipes/rspec/let_it_be'
 require 'test_prof/factory_default'
+require 'parslet/rig/rspec'
 
 rspec_profiling_is_configured =
   ENV['RSPEC_PROFILING_POSTGRES_URL'].present? ||