diff --git a/.github/workflows/ci_build_major_branch.yml b/.github/workflows/ci_build_major_branch.yml
index 77755ba71f..0e772c7d53 100644
--- a/.github/workflows/ci_build_major_branch.yml
+++ b/.github/workflows/ci_build_major_branch.yml
@@ -77,44 +77,56 @@ jobs:
     runs-on: ubuntu-latest
 
     steps:
+      - name: Disable safe.directory check
+        run: |
+          git config --global --add safe.directory '*'
+
+      - name: Checkout QMK Firmware
+        uses: actions/checkout@v4
+
       - name: Download firmwares
         uses: actions/download-artifact@v4
         with:
           pattern: firmware-*
-          path: firmwares
+          path: .
           merge-multiple: true
 
+      - name: Generate index page
+        run: |
+          python3 -m pip install -r ./util/ci/requirements.txt
+          ./util/ci/index_generator.py > index.html
+
       - name: Upload to https://ci.qmk.fm/${{ inputs.branch || github.ref_name }}/${{ github.sha }}
         uses: jakejarvis/s3-sync-action@master
         with:
-          args: --acl public-read --follow-symlinks --delete
+          args: --acl public-read --follow-symlinks --delete --exclude '*' --include 'index.html' --include '*.hex' --include '*.bin' --include '*.uf2'
         env:
           AWS_S3_BUCKET: ${{ vars.CI_QMK_FM_SPACES_BUCKET }}
           AWS_ACCESS_KEY_ID: ${{ secrets.CI_QMK_FM_SPACES_KEY }}
           AWS_SECRET_ACCESS_KEY: ${{ secrets.CI_QMK_FM_SPACES_SECRET }}
           AWS_REGION: ${{ vars.CI_QMK_FM_SPACES_REGION }}
           AWS_S3_ENDPOINT: ${{ vars.CI_QMK_FM_SPACES_ENDPOINT }}
-          SOURCE_DIR: firmwares
+          SOURCE_DIR: .
           DEST_DIR: ${{ inputs.branch || github.ref_name }}/${{ github.sha }}
 
       - name: Upload to https://ci.qmk.fm/${{ inputs.branch || github.ref_name }}/latest
         uses: jakejarvis/s3-sync-action@master
         with:
-          args: --acl public-read --follow-symlinks --delete
+          args: --acl public-read --follow-symlinks --delete --exclude '*' --include 'index.html' --include '*.hex' --include '*.bin' --include '*.uf2'
         env:
           AWS_S3_BUCKET: ${{ vars.CI_QMK_FM_SPACES_BUCKET }}
           AWS_ACCESS_KEY_ID: ${{ secrets.CI_QMK_FM_SPACES_KEY }}
           AWS_SECRET_ACCESS_KEY: ${{ secrets.CI_QMK_FM_SPACES_SECRET }}
           AWS_REGION: ${{ vars.CI_QMK_FM_SPACES_REGION }}
           AWS_S3_ENDPOINT: ${{ vars.CI_QMK_FM_SPACES_ENDPOINT }}
-          SOURCE_DIR: firmwares
+          SOURCE_DIR: .
           DEST_DIR: ${{ inputs.branch || github.ref_name }}/latest
 
       - name: Check if failure marker file exists
         id: check_failure_marker
         uses: andstor/file-existence-action@v3
         with:
-          files: firmwares/.failed
+          files: ./.failed
 
       - name: Fail build if needed
         if: steps.check_failure_marker.outputs.files_exists == 'true'
diff --git a/.gitignore b/.gitignore
index 35b128606d..5de38c161c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -25,6 +25,7 @@
 *.la
 *.stackdump
 *.sym
+index.html
 
 # QMK-specific
 api_data/v1
diff --git a/util/ci/index_generator.py b/util/ci/index_generator.py
new file mode 100755
index 0000000000..b6440bbffb
--- /dev/null
+++ b/util/ci/index_generator.py
@@ -0,0 +1,109 @@
+#!/usr/bin/env python3
+
+import os
+import re
+import shlex
+import subprocess
+from pathlib import Path
+
+from ansi2html import Ansi2HTMLConverter
+from jinja2 import Environment, FileSystemLoader, select_autoescape
+
+orig_cwd = os.getcwd()
+qmk_firmware_dir = Path(os.path.realpath(__file__)).parents[2]
+build_dir = qmk_firmware_dir / ".build"
+
+KEYBOARD_PATTERN = re.compile("CI Metadata: KEYBOARD=(?P<keyboard>.*)\r?\n")
+KEYMAP_PATTERN = re.compile("CI Metadata: KEYMAP=(?P<keymap>.*)\r?\n")
+
+env = Environment(
+    loader=FileSystemLoader(Path(os.path.realpath(__file__)).parent / "templates"),
+    autoescape=select_autoescape(),
+)
+
+
+def _run(command, capture_output=True, combined_output=False, text=True, **kwargs):
+    if isinstance(command, str):
+        command = shlex.split(command)
+    if capture_output:
+        kwargs["stdout"] = subprocess.PIPE
+        kwargs["stderr"] = subprocess.PIPE
+    if combined_output:
+        kwargs["stderr"] = subprocess.STDOUT
+    if "stdin" in kwargs and kwargs["stdin"] is None:
+        del kwargs["stdin"]
+    if text:
+        kwargs["universal_newlines"] = True
+    return subprocess.run(command, **kwargs)
+
+
+def _ansi2html(value):
+    return Ansi2HTMLConverter().convert(value, full=False)
+
+
+def _ansi2html_styles():
+    from ansi2html.style import get_styles
+
+    styles = get_styles(scheme="dracula")
+    return "\n".join([str(s) for s in styles])
+
+
+def _git_log(count = 4):
+    os.chdir(qmk_firmware_dir)
+    ret = _run(f"git log -n {count} --color=always --no-merges --topo-order --stat").stdout.strip()
+    os.chdir(orig_cwd)
+    return ret
+
+def _git_describe():
+    os.chdir(qmk_firmware_dir)
+    ret = _run("git describe --tags --always --dirty").stdout.strip()
+    os.chdir(orig_cwd)
+    return ret
+
+def _git_revision():
+    os.chdir(qmk_firmware_dir)
+    ret = _run("git rev-parse HEAD").stdout.strip()
+    os.chdir(orig_cwd)
+    return ret
+
+
+env.filters["ansi2html"] = _ansi2html
+
+binaries = []
+binaries.extend(qmk_firmware_dir.glob("*.bin"))
+binaries.extend(qmk_firmware_dir.glob("*.hex"))
+binaries.extend(qmk_firmware_dir.glob("*.uf2"))
+binaries = list(sorted(binaries))
+
+failures = []
+for mdfile in list(sorted(build_dir.glob("failed.log.*"))):
+    txt = Path(mdfile).read_text()
+
+    m_kb = KEYBOARD_PATTERN.search(txt)
+    if not m_kb:
+        raise Exception("Couldn't determine the keyboard from the failure output")
+    m_km = KEYMAP_PATTERN.search(txt)
+    if not m_km:
+        raise Exception("Couldn't determine the keymap from the failure output")
+
+    txt = KEYBOARD_PATTERN.sub("", KEYMAP_PATTERN.sub("", txt)).strip()
+
+    failures.append(
+        {
+            "stdout": txt,
+            "keyboard": m_kb.group("keyboard"),
+            "keymap": m_km.group("keymap"),
+        }
+    )
+
+template = env.get_template("index.html.j2")
+print(
+    template.render(
+        ansi2html_styles=_ansi2html_styles(),
+        git_log=_git_log(),
+        git_describe=_git_describe(),
+        git_revision=_git_revision(),
+        binaries=binaries,
+        failures=failures,
+    )
+)
diff --git a/util/ci/requirements.txt b/util/ci/requirements.txt
index 3196568e1a..47acf85644 100644
--- a/util/ci/requirements.txt
+++ b/util/ci/requirements.txt
@@ -1 +1,3 @@
 discord-webhook
+Jinja2
+ansi2html
diff --git a/util/ci/templates/index.html.j2 b/util/ci/templates/index.html.j2
new file mode 100644
index 0000000000..18086987db
--- /dev/null
+++ b/util/ci/templates/index.html.j2
@@ -0,0 +1,139 @@
+<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
+<html>
+
+<head>
+    <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
+    <style type="text/css">
+        {{ ansi2html_styles }}
+
+        body {
+            color: #FFF;
+            background-color: #111;
+        }
+
+        h3 {
+            font-family: sans-serif;
+            margin-top: 0;
+        }
+
+        a {
+            color: #00e1ff;
+            text-decoration: none;
+        }
+
+        a:hover {
+            color: #f700ff;
+            text-decoration: underline;
+        }
+
+        a:visited {
+            color: #00e1ff;
+        }
+
+        .build-target {
+            background-color: #333;
+            font-family: monospace;
+            padding-left: 0.3em;
+            padding-right: 0.3em;
+        }
+
+        .binary-link {
+            margin: 0;
+            margin-top: 0.25em;
+        }
+
+        .container {
+            background-color: #222;
+            padding: 2.0em;
+            margin: 2.0em;
+            border-radius: 2.0em;
+        }
+
+        .header-container {
+            display: table-cell;
+            padding-left: 1.5em;
+            vertical-align: middle;
+            font-family: sans-serif;
+        }
+
+        .header-container div:not(:first-child) {
+            margin-top: 0.25em;
+        }
+    </style>
+</head>
+
+<body class="" style="font-size: normal;">
+    <div style="float: left">
+        <div class="container">
+            <div style="display: table-cell; vertical-align: middle;">
+                <a href="https://qmk.fm/"><img src="https://qmk.fm/assets/images/badge-community-dark.svg" style="width: 30em;" /></a>
+            </div>
+            <div class="header-container">
+                <div>
+                    <span style="font-size: 175%; font-weight: bold;">CI Build {% if failures | length > 0 %}&#10060;{% else %}&#9989;{% endif %}</span>
+                </div>
+                <div>
+                    <span style="font-size: 100%; font-family: monospace;">{{ git_describe }}</span>
+                </div>
+                <div>
+                    <span style="font-size: 100%; font-family: monospace;">{{ git_revision }}</span>
+                </div>
+                <div>
+                    <span style="font-size: 80%; font-weight: bold; color: {% if failures | length > 0 %}#F00{% else %}#0F0{% endif %};">{{ failures | length }} failure{% if failures | length != 1 %}s{% endif %}</span>
+                </div>
+                <div>
+                    <span style="font-size: 80%">
+                    {% if binaries | length > 0 %}<a href="#firmwares">Firmwares</a> | {% endif %}
+                    <a href="#commit_info">Commit info</a>
+                    {% if failures | length > 0 %} | <a href="#failure_logs">Failure Logs</a>{% endif %}
+                    </span>
+                </div>
+            </div>
+        </div>
+        <a name="commit_info"></a>
+        <div class="container">
+            <h3>Git commit</h3>
+            <div class="body_foreground body_background"
+                style="display: table-cell; padding: 1.0em; border-radius: 1.0em;">
+                <pre class="ansi2html-content">{{ git_log | ansi2html }}</pre>
+            </div>
+        </div>
+        {% if failures | length > 0 %}
+        <a name="failure_logs"></a>
+        <div class="container">
+            <h3>Build failure logs</h3>
+            <ul>
+                {% for failure in failures %}
+                <li><a style="font-family: monospace;" href="#build_failure_{{ failure.keyboard }}_{{ failure.keymap }}">{{ failure.keyboard }}:{{ failure.keymap }}</a></li>
+                {% endfor %}
+            </ul>
+        </div>
+        {% for failure in failures %}
+        <a name="build_failure_{{ failure.keyboard }}_{{ failure.keymap }}"></a>
+        <div class="container">
+            <h3>Build failure &mdash; <span class="build-target">{{ failure.keyboard }}:{{ failure.keymap }}</span></h3>
+            <div class="body_foreground body_background"
+                style="display: table-cell; padding: 1.0em; border-radius: 1.0em;">
+                <pre class="ansi2html-content">{{ failure.stdout | ansi2html }}</pre>
+            </div>
+        </div>
+        {% endfor %}
+        {% endif %}
+    </div>
+    {% if binaries | length > 0 %}
+    <div style="float: right">
+        <a name="firmwares"></a>
+        <div class="container">
+            <h3>Firmware downloads</h3>
+            <div class="body_foreground body_background"
+                style="display: table-cell; padding: 1.0em; border-radius: 1.0em;">
+                {% for binary in binaries %}
+                <p class="binary-link" style="font-family: monospace;"><a href="{{ binary.name }}">{{ binary.name }}</a></p>
+                {%- endfor %}
+            </div>
+        </div>
+    </div>
+    {% endif %}
+</body>
+
+</html>