diff --git a/CHANGELOG b/CHANGELOG
index 80426a6ba870884946f08e38bad21947bed85d09..d81a1891e74dc04eacbbfb2525357709e522157f 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -1,7 +1,5 @@
 # Changelog for gitlab-workhorse
 
-Formerly known as 'gitlab-git-http-server'.
-
 v 8.30.0
 
 - Proxy ActionCable websocket connection !454
diff --git a/Makefile b/Makefile
index da38c47637de5c66c1b69e4c473af1840eed51ba..49b98ebbcee793d8c1d3734adee708f5f5976299 100644
--- a/Makefile
+++ b/Makefile
@@ -81,15 +81,19 @@ clean-workhorse:
 	$(call message,$@)
 	rm -f $(EXE_ALL)
 
+.PHONY: check-version
+check-version:
+	@test -n "$(VERSION)" || (echo "VERSION not set." ; exit 1)
+
 .PHONY:	tag
-tag:
+tag: check-version
 	$(call message,$@)
-	sh _support/tag.sh
+	sh _support/tag.sh "$(VERSION)"
 
 .PHONY:	signed_tag
-signed_tag:
+signed_tag: check-version
 	$(call message,$@)
-	TAG_OPTS=-s sh _support/tag.sh
+	TAG_OPTS=-s sh _support/tag.sh "$(VERSION)"
 
 .PHONY:	clean-build
 clean-build:
diff --git a/PROCESS.md b/PROCESS.md
index a82d7e5165d94ccb0c8ac92bcd30c5c839d84220..6fcf65a7afa829d77154f8da376ef5882a7e3e3f 100644
--- a/PROCESS.md
+++ b/PROCESS.md
@@ -22,13 +22,9 @@ maintainers. The release process is:
 
 -   pick a release branch. For x.y.0, use `master`. For all other
     versions (x.y.1, x.y.2 etc.) , use `x-y-stable`. Also see [below](#versioning)
--   create a merge request to update CHANGELOG and VERSION on the
-    release branch
--   You can use `git log --first-parent <last-version>..master` to see the list of changes
--   merge the merge request
--   run `make tag` or `make signed_tag` on the release branch. This will
-    make a tag matching the VERSION file.
--   push the tag to gitlab.com
+-   run `make tag VERSION=x.y.z"` or `make signed_tag VERSION=x.y.z` on the release branch. This will
+    compile the changelog, bump the VERSION file, and make a tag matching it.
+-   push the branch and the tag to gitlab.com
 
 ## Versioning
 
diff --git a/_support/changelog b/_support/changelog
new file mode 100755
index 0000000000000000000000000000000000000000..0e733cc00620fa0e7191a59b757c987ef8046b33
--- /dev/null
+++ b/_support/changelog
@@ -0,0 +1,243 @@
+#!/usr/bin/env ruby
+#
+# Generate a changelog entry file in the correct location.
+#
+# Automatically stages the file and amends the previous commit if the `--amend`
+# argument is used.
+#
+# Stolen from gitlab-org/gitaly, lifted from gitlab-org/gitlab-ce
+
+require 'optparse'
+require 'yaml'
+
+Options = Struct.new(
+  :amend,
+  :author,
+  :dry_run,
+  :force,
+  :merge_request,
+  :title,
+  :type
+)
+INVALID_TYPE = -1
+
+class ChangelogOptionParser
+  Type = Struct.new(:name, :description)
+  TYPES = [
+    Type.new('added', 'New feature'),
+    Type.new('fixed', 'Bug fix'),
+    Type.new('changed', 'Feature change'),
+    Type.new('deprecated', 'New deprecation'),
+    Type.new('removed', 'Feature removal'),
+    Type.new('security', 'Security fix'),
+    Type.new('performance', 'Performance improvement'),
+    Type.new('other', 'Other')
+  ].freeze
+  TYPES_OFFSET = 1
+
+  class << self
+    def parse(argv)
+      options = Options.new
+
+      parser = OptionParser.new do |opts|
+        opts.banner = "Usage: #{__FILE__} [options] [title]\n\n"
+
+        # Note: We do not provide a shorthand for this in order to match the `git
+        # commit` interface
+        opts.on('--amend', 'Amend the previous commit') do |value|
+          options.amend = value
+        end
+
+        opts.on('-f', '--force', 'Overwrite an existing entry') do |value|
+          options.force = value
+        end
+
+        opts.on('-m', '--merge-request [integer]', Integer, 'Merge Request ID') do |value|
+          options.merge_request = value
+        end
+
+        opts.on('-n', '--dry-run', "Don't actually write anything, just print") do |value|
+          options.dry_run = value
+        end
+
+        opts.on('-u', '--git-username', 'Use Git user.name configuration as the author') do |value|
+          options.author = git_user_name if value
+        end
+
+        opts.on('-t', '--type [string]', String, "The category of the change, valid options are: #{TYPES.map(&:name).join(', ')}") do |value|
+          options.type = parse_type(value)
+        end
+
+        opts.on('-h', '--help', 'Print help message') do
+          $stdout.puts opts
+          exit
+        end
+      end
+
+      parser.parse!(argv)
+
+      # Title is everything that remains, but let's clean it up a bit
+      options.title = argv.join(' ').strip.squeeze(' ').tr("\r\n", '')
+
+      options
+    end
+
+    def read_type
+      read_type_message
+
+      type = TYPES[$stdin.getc.to_i - TYPES_OFFSET]
+      assert_valid_type!(type)
+
+      type.name
+    end
+
+    private
+
+    def parse_type(name)
+      type_found = TYPES.find do |type|
+        type.name == name
+      end
+      type_found ? type_found.name : INVALID_TYPE
+    end
+
+    def read_type_message
+      $stdout.puts "\n>> Please specify the index for the category of your change:"
+      TYPES.each_with_index do |type, index|
+        $stdout.puts "#{index + TYPES_OFFSET}. #{type.description}"
+      end
+      $stdout.print "\n?> "
+    end
+
+    def assert_valid_type!(type)
+      unless type
+        $stderr.puts "Invalid category index, please select an index between 1 and #{TYPES.length}"
+        exit 1
+      end
+    end
+
+    def git_user_name
+      %x{git config user.name}.strip
+    end
+  end
+end
+
+class ChangelogEntry
+  attr_reader :options
+
+  def initialize(options)
+    @options = options
+
+    assert_feature_branch!
+    assert_title!
+    assert_new_file!
+
+    # Read type from $stdin unless is already set
+    options.type ||= ChangelogOptionParser.read_type
+    assert_valid_type!
+
+    $stdout.puts "\e[32mcreate\e[0m #{file_path}"
+    $stdout.puts contents
+
+    unless options.dry_run
+      write
+      amend_commit if options.amend
+    end
+  end
+
+  private
+
+  def contents
+    yaml_content = YAML.dump(
+      'title'         => title,
+      'merge_request' => options.merge_request,
+      'author'        => options.author,
+      'type'          => options.type
+    )
+    remove_trailing_whitespace(yaml_content)
+  end
+
+  def write
+    File.write(file_path, contents)
+  end
+
+  def amend_commit
+    %x{git add #{file_path}}
+    exec("git commit --amend")
+  end
+
+  def fail_with(message)
+    $stderr.puts "\e[31merror\e[0m #{message}"
+    exit 1
+  end
+
+  def assert_feature_branch!
+    return unless branch_name == 'master'
+
+    fail_with "Create a branch first!"
+  end
+
+  def assert_new_file!
+    return unless File.exist?(file_path)
+    return if options.force
+
+    fail_with "#{file_path} already exists! Use `--force` to overwrite."
+  end
+
+  def assert_title!
+    return if options.title.length > 0 || options.amend
+
+    fail_with "Provide a title for the changelog entry or use `--amend`" \
+      " to use the title from the previous commit."
+  end
+
+  def assert_valid_type!
+    return unless options.type && options.type == INVALID_TYPE
+
+    fail_with 'Invalid category given!'
+  end
+
+  def title
+    if options.title.empty?
+      last_commit_subject
+    else
+      options.title
+    end
+  end
+
+  def last_commit_subject
+    %x{git log --format="%s" -1}.strip
+  end
+
+  def file_path
+    File.join(
+      unreleased_path,
+      branch_name.gsub(/[^\w-]/, '-') << '.yml'
+    )
+  end
+
+  def unreleased_path
+    path = File.join('changelogs', 'unreleased')
+    path = File.join('ee', path) if ee?
+
+    path
+  end
+
+  def ee?
+    @ee ||= File.exist?(File.expand_path('../CHANGELOG-EE.md', __dir__))
+  end
+
+  def branch_name
+    @branch_name ||= %x{git symbolic-ref --short HEAD}.strip
+  end
+
+  def remove_trailing_whitespace(yaml_content)
+    yaml_content.gsub(/ +$/, '')
+  end
+end
+
+if $0 == __FILE__
+  options = ChangelogOptionParser.parse(ARGV)
+  ChangelogEntry.new(options)
+end
+
+# vim: ft=ruby
diff --git a/_support/generate_changelog b/_support/generate_changelog
new file mode 100755
index 0000000000000000000000000000000000000000..a9a8bae5a2520ddff2bc431488bee1db23263b15
--- /dev/null
+++ b/_support/generate_changelog
@@ -0,0 +1,75 @@
+#!/usr/bin/env ruby
+# Generates the changelog from the yaml entries in changelogs/unreleased
+#
+# Lifted form gitlab-org/gitaly
+
+require 'yaml'
+require 'fileutils'
+
+class ChangelogEntry
+  attr_reader :title, :merge_request, :type, :author
+
+  def initialize(file_path)
+    yaml = YAML.safe_load(File.read(file_path))
+
+    @title = yaml['title']
+    @merge_request = yaml['merge_request']
+    @type = yaml['type']
+    @author = yaml['author']
+  end
+
+  def to_s
+    str = ""
+    str << "- #{title}\n"
+    str << "  https://gitlab.com/gitlab-org/gitlab-workhorse/-/merge_requests/#{merge_request}\n"
+    str << "  Contributed by #{author}\n" if author
+
+    str
+  end
+end
+
+ROOT_DIR = File.expand_path('../..', __FILE__)
+UNRELEASED_ENTRIES = File.join(ROOT_DIR, 'changelogs', 'unreleased')
+CHANGELOG_FILE = File.join(ROOT_DIR, 'CHANGELOG')
+
+def main(version)
+  entries = []
+  Dir["#{UNRELEASED_ENTRIES}/*.yml"].each do |yml|
+    entries << ChangelogEntry.new(yml)
+    FileUtils.rm(yml)
+  end
+
+  sections = []
+  types = entries.map(&:type).uniq.sort
+  types.each do |type|
+    text = ''
+    text << "### #{type.capitalize}\n"
+
+    entries.each do |e|
+      next unless e.type == type
+
+      text << e.to_s
+    end
+
+    sections << text
+  end
+
+  sections << '- No changes.' if sections.empty?
+
+  new_version_entry = ["## v#{version}\n\n", sections.join("\n"), "\n"].join
+
+  current_changelog = File.read(CHANGELOG_FILE).lines
+  header = current_changelog.shift(2)
+
+  new_changelog = [header, new_version_entry, current_changelog.join]
+
+  File.write(CHANGELOG_FILE, new_changelog.join)
+end
+
+unless ARGV.count == 1
+  warn "Usage: #{$0} VERSION"
+  warn "Specify version as x.y.z"
+  abort
+end
+
+main(ARGV.first)
diff --git a/_support/tag.sh b/_support/tag.sh
index d1b0aebfab072ac9f395564d2bdfe1c2dd952512..639fd141dad007e93e2b02b65647b567303b8420 100644
--- a/_support/tag.sh
+++ b/_support/tag.sh
@@ -1,7 +1,13 @@
 set -e
 
 main() {
-  get_version
+  version=$1
+  set_version
+
+  changelog
+
+  git commit VERSION -m "Update VERSION to $version"
+
   tag_name="v${version}"
   git tag $TAG_OPTS -m "Version ${version}" -a ${tag_name}
   git show ${tag_name}
@@ -12,13 +18,28 @@ main() {
 EOF
 }
 
-get_version() {
-  v=$(sed 1q VERSION)
-  if ! echo "${v}" | grep -q '^[0-9]\+\.[0-9]\+\.[0-9]\+$' ; then
-    echo "Invalid VERSION: ${v}"
+set_version() {
+  if ! echo "${version}" | grep -q '^[0-9]\+\.[0-9]\+\.[0-9]\+$' ; then
+    echo "Invalid VERSION: ${version}"
+    exit 1
+  fi
+
+  if git tag --list | grep -q "^v${version}$" ; then
+    echo "Tag already exists for ${version}"
     exit 1
   fi
-  version="${v}"
+
+  echo "$version" > VERSION
+}
+
+changelog() {
+  _support/generate_changelog "$version"
+
+  git commit CHANGELOG changelogs/unreleased --file - <<EOF
+Update CHANGELOG for ${version}
+
+[ci skip]
+EOF
 }
 
-main
\ No newline at end of file
+main "$@"
diff --git a/changelogs/unreleased/.gitkeep b/changelogs/unreleased/.gitkeep
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/changelogs/unreleased/changelog-generator.yml b/changelogs/unreleased/changelog-generator.yml
new file mode 100644
index 0000000000000000000000000000000000000000..198ccc77515a9974d2785785b030856a724148e5
--- /dev/null
+++ b/changelogs/unreleased/changelog-generator.yml
@@ -0,0 +1,5 @@
+---
+title: Add automatic changelog generation
+merge_request: 484
+author:
+type: other