diff --git a/changelog_generator.py b/changelog_generator.py
new file mode 100755
index 0000000000000000000000000000000000000000..c6aea7f0e486292a8d8aca0847ed3a1cacee6a7c
--- /dev/null
+++ b/changelog_generator.py
@@ -0,0 +1,308 @@
+#!/usr/bin/env python3
+""" 
+
+Simple changelog generator for Garz&Fricke gitlab projects.
+
+Queries merge request from gitlab and outputs as sorted list in
+markdown format.
+
+Releases are tags on the given branch in the manifest project.
+Changes to list are merged mergerequests with the given branch
+as target. Match with releases is done by the timestamp (merged_at)
+in comparison with the tags timestamp.
+
+"""
+
+
+import sys
+import datetime
+import gitlab as gl
+import argparse
+
+__author__ = "Jonas Höppner"
+__email__ = "jonas.hoeppner@garz-fricke.com"
+
+GITLAB_SERVER = "https://git.seco.com"
+# ID of the guf_yocto group
+GITLAB_GROUP_ID = "556"
+
+DISTRO_PROJECT_ID = "1748"
+MACHINE_PROJECT_ID = "1747"
+MANIFEST_PROJECT_ID = "1725"
+
+DEFAULTBRANCH = "dunfell"
+
+GITLAB_TIMEFORMAT = "%Y-%m-%dT%H:%M:%S.%f%z"
+TIMEFORMAT = "%Y-%m-%d %H:%M"
+TIMEFORMAT_DEBUG = "%Y-%m-%d %H:%M.%f %z"
+
+verbose = 0
+
+
+def print_dbg(*args, **kwargs):
+    if verbose < 1:
+        return
+    print(*args, file=sys.stderr, **kwargs)
+
+
+def decode_timestamp(t):
+    timestamp = datetime.datetime.strptime(t, GITLAB_TIMEFORMAT)
+    return timestamp
+
+
+class Project:
+    def __init__(self, project):
+        self.project = project
+
+    def __str__(self):
+        return "## Project " + self.project.name + "\n"
+
+    def withlink(self):
+        return (
+            "\n\n## Project [" + self.project.name + "](" + self.project.web_url + ")\n"
+        )
+
+    def __eq__(self, p):
+        if not p:
+            return False
+        return self.project.id == p.project.id
+
+
+class Tag:
+    def __init__(self, tag):
+        self.name = tag.name
+        self.message = tag.message
+        self.commit = tag.commit
+        """
+        The tags timestamp is a little more complicated it normally points 
+        to the tagged commit's timestamps. But the merge happens later.
+        To handle this, the relelated mergerequest is found by comparing the
+        sha's and also take the merged_at timestamp.
+        """
+        self.timestamp = decode_timestamp(tag.commit["created_at"])
+
+        """
+        The mr which introduced the taged commit
+        as gitlab-python does not support the V5 API yet
+        this is added later when traversing the mrs anyway
+        with V5 Api: https://docs.gitlab.com/ee/api/commits.html#list-merge-requests-associated-with-a-commit
+        """
+        self.mergerequest = None
+        print_dbg(self.name + "  -- " + self.commit["id"])
+
+    def __str__(self):
+        return self.name + " " + self.timestamp.strftime(TIMEFORMAT)
+
+    def add_mergerequest(self, m):
+        if self.mergerequest:
+            return
+        if m.mr.sha == self.commit["id"]:
+            self.mergerequest = m
+            # Update timestamp
+            # The tag points to the commit, but the merge of the merge request may has happend later
+            # as the commit, so the merged_at date is relevant. Otherwise the tagged commit and may be
+            # more end up in the wrong release
+            new_timestamp = decode_timestamp(self.mergerequest.mr.merged_at)
+            print_dbg("Found matching merge request for ", self)
+            print_dbg(" - " + self.timestamp.strftime(TIMEFORMAT))
+            print_dbg(" - " + new_timestamp.strftime(TIMEFORMAT))
+            self.timestamp = new_timestamp
+
+    def header(self):
+        return (
+            "\n\n\n# Release "
+            + self.name
+            + "\n\nreleased at "
+            + self.timestamp.strftime(TIMEFORMAT)
+            + "\n\n"
+        )
+
+
+class DummyTag:
+    def __init__(
+        self, name, message, date=datetime.datetime.now(tz=datetime.timezone.utc)
+    ):
+        self.name = name
+        self.message = message
+        self.timestamp = date
+
+    def header(self):
+        return "\n\n\n# " + self.name + "\n\n"
+
+    def add_mergerequest(self, m):
+        # Needed as interface but does nothing
+        pass
+
+
+class Release:
+    """Store some release data"""
+
+    def __init__(self, tag):
+        self.tag = tag
+        self.mergerequests = []
+
+    def add_mergerequest(self, m):
+        # Check if this merge_request is related to the tag
+        self.tag.add_mergerequest(m)
+
+        # Adds a mergerequest to the project, but uses some filtering
+        # Ignore automated merge requests
+        if m.mr.author["username"] == "guf-gitbot":
+            return False
+        if m.mr.author["username"] == "gitbot":
+            return False
+        # With the movement to git.seco.com the MRs owned by
+        # the guf-gitbot have been transfered to tobias
+        # As it is not possible to change the owner back
+        # to gitbot we need an extra filter here on the
+        # branch name
+        if m.mr.source_branch.startswith('integrate/'):
+            return False
+
+
+        # Timestamp is not in this release
+        if self.tag.timestamp < m.timestamp:
+            return False
+
+        # Remove duplicates, don't print the same title
+        # twice in the same project and release
+        if any(
+            a.mr.title == m.mr.title and a.project == m.project
+            for a in self.mergerequests
+        ):
+            return True
+
+        self.mergerequests.append(m)
+        return True
+
+    def header(self):
+        return self.tag.header()
+
+    def description(self):
+        m = self.tag.message
+        if not m:
+            return ""
+        return m
+
+    def __str__(self):
+        return self.tag.name
+
+
+class MergeRequest:
+    def __init__(self, mr, p):
+        self.mr = mr
+        self.project = p
+        self.timestamp = decode_timestamp(self.mr.merged_at)
+        print_dbg("\nMergeRequest:")
+        print_dbg(mr)
+
+    def __str__(self):
+        return self.mr.title
+
+    def withlink(self):
+        out = self.mr.title + " [" + self.mr.reference + "](" + self.mr.web_url + ")"
+        if verbose > 1:
+            out += " " + self.timestamp.strftime(TIMEFORMAT)
+        return out
+
+
+def main(args):
+    global verbose
+    global TIMEFORMAT
+
+    parser = argparse.ArgumentParser(description=__doc__, usage="%(prog)s [OPTIONS]")
+
+    parser.add_argument(
+        "--gitlab-url",
+        help="""URL to the GitLab instance""",
+        dest="gitlab_url",
+        action="store",
+        default=GITLAB_SERVER,
+    )
+    parser.add_argument(
+        "--token",
+        help="""GitLab REST API private access token""",
+        dest="token",
+        required=True,
+    )
+    parser.add_argument(
+        "-b",
+        "--branch",
+        action="store",
+        dest="branch",
+        default=DEFAULTBRANCH,
+        help=("Specify the branch to work on, default is dunfell."),
+    )
+
+    parser.add_argument(
+        "-v",
+        "--verbose",
+        action="count",
+        dest="verbose",
+        default=0,
+        help=("Increase verbosity."),
+    )
+
+    options = parser.parse_args(args)
+    verbose = options.verbose
+    if verbose > 1:
+        TIMEFORMAT = TIMEFORMAT_DEBUG
+
+    print_dbg(options)
+    gitlab = gl.Gitlab(options.gitlab_url, private_token=options.token)
+
+    # Speed up, complete project lookup takes much longer
+    # then specifying the ID directly
+    distro = Project(gitlab.projects.get(DISTRO_PROJECT_ID))
+    machine = Project(gitlab.projects.get(MACHINE_PROJECT_ID))
+    manifest = Project(gitlab.projects.get(MANIFEST_PROJECT_ID))
+
+    releases = []
+    for t in manifest.project.tags.list(search=options.branch):
+        releases.append(Release(Tag(t)))
+
+    # Add dummy release with date today for new untaged commits
+    releases.append(
+        Release(
+            DummyTag(
+                "Not yet released",
+                "Merged Request already merged into "
+                + options.branch
+                + " but not yet released.",
+            )
+        )
+    )
+
+    # Sort by date, oldest first
+    releases = sorted(releases, key=lambda d: d.tag.timestamp, reverse=False)
+
+    for p in [manifest, distro, machine]:
+        for mr in p.project.mergerequests.list(
+            scope="all", state="merged", target_branch=options.branch, per_page="10000"
+        ):
+            m = MergeRequest(mr, p)
+            for r in releases:
+                if r.add_mergerequest(m):
+                    break
+
+    # Sort by date, newest first
+    releases = sorted(releases, key=lambda d: d.tag.timestamp, reverse=True)
+
+    for r in releases:
+        # Don't show empty releases/tags
+        if not len(r.mergerequests):
+            continue
+
+        print(r.header())
+        print(r.description())
+
+        current_project = None
+        for m in r.mergerequests:
+            if m.project != current_project:
+                current_project = m.project
+                print(current_project.withlink())
+            print(" - ", m.withlink())
+
+
+if __name__ == "__main__":
+    main(sys.argv[1:])
diff --git a/manifest.yml b/manifest.yml
index 7fca588624b814a8445cf353395a14a109f13fcd..9a78952849d67b3061e7aad5d4df9e26b914f2af 100644
--- a/manifest.yml
+++ b/manifest.yml
@@ -83,14 +83,7 @@ changelog:
   extends: .infrastructure
   rules:
     - if: $CI_COMMIT_REF_NAME != $MASTER_BRANCH_MANIFEST || $CI_PIPELINE_SOURCE == "api"
-  variables:
-    IMAGE_PATH: ${CI_IMAGES_BASEPATH}/changelog-generator
-    IMAGE_REVISION: fe31a5ffe75b8f0dca697dd95ffb3f87af92d6d1
-  image:
-    name: "${IMAGE_PATH}:${IMAGE_REVISION}"
-    # set entrypoint to noop to be able to run from script
-    entrypoint: [""]
-  script: changelog_generator.py
+  script: .gitlab-ci/changelog_generator.py
               --token=${GITBOT_TOKEN}
               --branch ${MASTER_BRANCH_MANIFEST}
               > changelog.md