Reland "Add in the script to generate the monthly release notes for docker."

This reverts commit 9e1660e00ed725b3011998bcc87d9f1c2453a4c8.

Reason for revert: Checking - some CQ builds passed after the one that failed so perhaps just a flake.

Original change's description:
> Revert "Add in the script to generate the monthly release notes for docker."
>
> This reverts commit 1919dbe46923ec3ad43c27f4cb7feb3cf57b59c7.
>
> Reason for revert: this blocks our CQ
>
> ==================================== ERRORS ====================================
> ______ ERROR collecting servo/tests/unit/test_release_notes_generator.py _______
> ImportError while importing test module '/hdctools/servo/tests/unit/test_release_notes_generator.py'.
> Hint: make sure your test modules/packages have valid Python names.
> Traceback:
> /usr/lib/python3.11/importlib/__init__.py:126: in import_module
>     return _bootstrap._gcd_import(name[level:], package, level)
> /hdctools/servo/tests/unit/test_release_notes_generator.py:12: in <module>
>     from servo.dockerfiles.release_notes_generator import create_temp_file
> E   ModuleNotFoundError: No module named 'servo.dockerfiles'
> =============================== warnings summary ===============================
>
> Original change's description:
> > Add in the script to generate the monthly release notes for docker.
> >
> > BUG=b:416323226
> > TEST=pytest servo/tests/unit/test_release_notes_generator.py
> >
> > Change-Id: Ie96e762979aa43602b4bc543df6dfd4b54ec0bd9
> > Reviewed-on: https://chromium-review.googlesource.com/c/chromiumos/third_party/hdctools/+/6521039
> > Reviewed-by: Michał Headcrab Barnaś <barnas@google.com>
> > Commit-Queue: Łukasz Hajec <hajec@google.com>
> > Reviewed-by: 488603086791@cloudbuild.gserviceaccount.com <488603086791@cloudbuild.gserviceaccount.com>
> > Reviewed-by: Łukasz Hajec <hajec@google.com>
> > Auto-Submit: Keith Haddow <haddowk@chromium.org>
> > Tested-by: Keith Haddow <haddowk@chromium.org>
>
> BUG=b:416323226
>
> Change-Id: Ia29249176e1255e4a31281b9dca7eff1d5e5d442
> Reviewed-on: https://chromium-review.googlesource.com/c/chromiumos/third_party/hdctools/+/6641376
> Commit-Queue: Keith Haddow <haddowk@chromium.org>
> Tested-by: Keith Haddow <haddowk@chromium.org>
> Reviewed-by: 488603086791@cloudbuild.gserviceaccount.com <488603086791@cloudbuild.gserviceaccount.com>
> Reviewed-by: Keith Haddow <haddowk@chromium.org>

BUG=b:416323226

Change-Id: I194d3d093b4f6b4ba55787e9444bceb84faaa09a
Reviewed-on: https://chromium-review.googlesource.com/c/chromiumos/third_party/hdctools/+/6639247
Reviewed-by: 488603086791@cloudbuild.gserviceaccount.com <488603086791@cloudbuild.gserviceaccount.com>
Tested-by: Keith Haddow <haddowk@chromium.org>
Reviewed-by: Łukasz Hajec <hajec@google.com>
Commit-Queue: Łukasz Hajec <hajec@google.com>
Auto-Submit: Keith Haddow <haddowk@chromium.org>
diff --git a/pyproject.toml b/pyproject.toml
index 02dfb0a..613abc4 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -10,3 +10,8 @@
   .*/servo/data/.*
 )
 '''
+
+[tool.pytest.ini_options]
+pythonpath = [
+      ".",
+]
diff --git a/servo/dockerfiles/cloudbuild.yaml b/servo/dockerfiles/cloudbuild.yaml
index 876eddb..a1c41a7 100644
--- a/servo/dockerfiles/cloudbuild.yaml
+++ b/servo/dockerfiles/cloudbuild.yaml
@@ -31,8 +31,23 @@
   waitFor: ['src-prep']
 
 - name: 'us-docker.pkg.dev/$PROJECT_ID/servod/servod:$COMMIT_SHA'
+  id: 'test-prep'
+  entrypoint: 'bash'
+  args:
+  - '-c'
+  - |
+    set -x
+    cp /usr/local/lib/python3.11/dist-packages/servo/proto/servo_dev_info.textproto /workspace/servo/proto/
+    cp /usr/local/lib/python3.11/dist-packages/servo/proto/servo_dev_pb2.py /workspace/servo/proto/
+    cp /usr/local/lib/python3.11/dist-packages/servo/data/*_inas.xml  /workspace/servo/data/
+
+  waitFor: ['build']
+
+- name: 'us-docker.pkg.dev/$PROJECT_ID/servod/servod:$COMMIT_SHA'
   id: 'test-python'
   entrypoint: pytest
+  env:
+    - PYTHONPATH=/workspace/
   args:
     - -n=auto
     - --forked
@@ -40,8 +55,9 @@
     - --cov=/usr/local/lib/python3.11/dist-packages/servo
     - --cov=/usr/local/lib/python3.11/dist-packages/usbkm232
     - --cov=/usr/local/lib/python3.11/dist-packages/ec3po
-    - /hdctools/
-  waitFor: ['build']
+    - /workspace/
+  waitFor: ['test-prep']
+
 
 - name: 'pyfound/black:24.2.0'
   id: 'test-black-formatting'
diff --git a/servo/dockerfiles/release_notes_generator.py b/servo/dockerfiles/release_notes_generator.py
new file mode 100644
index 0000000..ebfbcb3
--- /dev/null
+++ b/servo/dockerfiles/release_notes_generator.py
@@ -0,0 +1,287 @@
+# Copyright 2025 The ChromiumOS Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+"""Automates the generation of release notes from version control history.
+
+1) Finds the 2 most recent release branches and extracts the git commits
+   and parses them.
+2) Asks you where they are categorized as.
+3) Generates an html page using a few templates.
+4) Opens the browser.
+
+You can then just copy-paste it into the email..
+
+To run the command - in a checked out hdctools repo:
+
+git pull
+git switch hdctools-release-$(date +"%m%y").1
+
+python3 servo/dockerfiles/release_notes_generator.py
+
+"""
+
+from collections import namedtuple
+import html
+import re
+import subprocess
+import tempfile
+
+
+def find_branches():
+    """Finds the two most recent hdctools-release branches from git.
+
+    It fetches all remote branches, filters for those matching the pattern
+    "origin/hdctools-release-MMYY...", sorts them by year and month in
+    descending order, and returns the names of the two most recent ones.
+
+    Returns:
+        tuple: A tuple containing two strings:
+            (newest_branch_name, second_newest_branch_name)
+    """
+    args = ["git", "branch", "-r"]
+    git_branch_lines = subprocess.check_output(args).decode().splitlines()
+    reg = r"origin/hdctools-release-(?P<month>\d\d)(?P<year>\d\d)\S*"
+    values = [re.search(reg, x) for x in git_branch_lines]
+    values = [x for x in values if x]
+    values.sort(reverse=True, key=lambda x: x.group("year") + x.group("month"))
+    values = [x.group() for x in values]
+    return values[0], values[1]
+
+
+def load_commits(commits_new_branch, commits_last_branch, cwd=None):
+    """Loads git commits between two specified branches.
+
+    It uses `git log` to retrieve the commit hash, subject (description),
+    and author name for commits in the range `last_branch...new_branch`.
+    Each commit's information is parsed and stored in a Commit namedtuple.
+
+    Args:
+        commits_new_branch (str): The newer branch name (or commit reference) to
+            end the log at (inclusive).
+        commits_last_branch (str): The older branch name (or commit reference) to
+            start the log from (exclusive).
+        cwd (str, optional): The current working directory to run git commands.
+            Defaults to None.
+
+    Returns:
+        list: A list of Commit namedtuples, where each tuple contains
+              (hash, desc, author).
+    """
+    delim = "␟"
+    args = [
+        "git",
+        "log",
+        f"--pretty=format:%H{delim}%s{delim}%aN",
+        f"{commits_last_branch}...{commits_new_branch}",
+    ]
+    response = subprocess.check_output(args, cwd=cwd).decode()
+    Commit = namedtuple("Commit", ["hash", "desc", "author"])
+    loaded_commits = []
+
+    for line in response.splitlines():
+        loaded_commits.append(Commit(*line.split(delim)))
+    return loaded_commits
+
+
+def organize_help(help_headers):
+    """Prints help message showing available categories for organizing commits.
+
+    Args:
+        help_headers (list): A list of strings, where each string is a category name.
+    """
+    print("Enter the number to organize the commit.")
+    for i, name in enumerate(help_headers):
+        print(f"{i}:{name}")
+
+
+def handle_response(response_headers):
+    """Handles user input for selecting a commit category.
+
+    Prompts the user for input, attempts to convert it to an integer,
+    and validates if it's a valid index for the provided headers list.
+
+    Args:
+        response_headers (list): A list of strings, representing available category
+                        names.
+
+    Returns:
+        str or None: The selected category name (string) if the input is valid,
+                     otherwise None.
+    """
+    response = "Invalid"
+    try:
+        response = input()
+        index = int(response)
+        if 0 <= index < len(response_headers):
+            return response_headers[index]
+    except (ValueError, KeyboardInterrupt):
+        pass
+    print(f"Invalid response: {repr(response)}")
+    return None
+
+
+def organize_commits(loaded_commits, commit_headers):
+    """Interactively organizes a list of commits into predefined categories.
+
+    For each commit, it prints the commit description and author, then prompts
+    the user to select a category from the provided headers. This process
+    repeats until all commits are categorized.
+
+    Args:
+        loaded_commits (list): A list of Commit namedtuples to be organized.
+        commit_headers (list): A list of strings, where each string is a category name
+                        to organize commits into.
+
+    Returns:
+        dict: A dictionary where keys are category names (from headers) and
+              values are lists of Commit namedtuples belonging to that category.
+    """
+    organized_commits = {x: [] for x in commit_headers}
+    organize_help(commit_headers)
+    for loaded_commit in loaded_commits:
+        while True:
+            print(f"{loaded_commit.desc: <60}\t({loaded_commit.author})")
+            key = handle_response(commit_headers)
+            if key is not None:
+                organized_commits[key].append(loaded_commit)
+                break
+            organize_help(commit_headers)
+    return organized_commits
+
+
+def format_header(name):
+    """Formats a category header name into an HTML string.
+
+    Escapes the name for HTML safety and wraps it in specific HTML
+    tags with styling for display in a document.
+
+    Args:
+        name (str): The category header name.
+
+    Returns:
+        str: An HTML string representing the formatted header.
+    """
+    name = html.escape(name)
+    template = (
+        "<br>"
+        '<p dir="ltr" style="line-height:1.38;margin-top:0pt;margin-bottom:0pt;'
+        '"><span style="font-size:10.5pt;font-family:Roboto,sans-serif;'
+        "color:#000000;background-color:transparent;font-weight:700;"
+        "font-style:normal;font-variant:normal;text-decoration:none;"
+        'vertical-align:baseline;white-space:pre;white-space:pre-wrap;">'
+        f"{name}"
+        '</span><span style="font-size:10.5pt;font-family:Roboto,sans-serif;'
+        "color:#000000;background-color:transparent;font-weight:400;"
+        "font-style:normal;font-variant:normal;text-decoration:none;"
+        "vertical-align:baseline;white-space:pre;white-space:pre-wrap;"
+        '"><span class="Apple-tab-span" style="white-space:pre;">'
+        ' </span></span><span style="font-size:10.5pt;'
+        "font-family:Roboto,sans-serif;color:#000000;"
+        "background-color:transparent;font-weight:400;font-style:normal;"
+        "font-variant:normal;text-decoration:none;vertical-align:baseline;"
+        'white-space:pre;white-space:pre-wrap;"><span class="Apple-tab-span" '
+        'style="white-space:pre;"> </span></span></p>'
+    )
+    return template
+
+
+def format_commit(commit):
+    """Formats a single commit's details into an HTML string.
+
+    Extracts the short hash, description, and author from the commit.
+    It creates a clickable link to the commit on googlesource.com using
+    the full hash. All parts are HTML-escaped and styled.
+
+    Args:
+        commit (namedtuple): A Commit namedtuple with 'hash', 'desc',
+                             and 'author' attributes.
+
+    Returns:
+        str: An HTML string representing the formatted commit.
+    """
+    short_hash = html.escape(f"{commit.hash[:8]}")
+    url = html.escape(f"https://chromium-review.googlesource.com/q/{commit.hash}")
+    desc = html.escape(commit.desc)
+    author = html.escape(f"({commit.author})")
+    template = (
+        '<p dir="ltr" style="line-height:1.38;margin-top:0pt;margin-bottom:0pt;'
+        '"><a href="'
+        f"{url}"
+        '" style="text-decoration:none;"><span style="font-size:9pt;'
+        "font-family:'Roboto Mono',monospace;color:#1155cc;"
+        "background-color:transparent;font-weight:400;font-style:normal;"
+        "font-variant:normal;text-decoration:underline;"
+        "-webkit-text-decoration-skip:none;text-decoration-skip-ink:none;"
+        'vertical-align:baseline;white-space:pre;white-space:pre-wrap;">'
+        f"{short_hash}"
+        '</span><span style="font-size:10.5pt;font-family:Roboto,sans-serif;'
+        "color:#000000;background-color:transparent;font-weight:400;"
+        "font-style:normal;font-variant:normal;text-decoration:none;"
+        "vertical-align:baseline;white-space:pre;white-space:pre-wrap;"
+        '"><span class="Apple-tab-span" style="white-space:pre;'
+        '"> </span></span></a><span style="font-size:10.5pt;'
+        "font-family:Roboto,sans-serif;color:#000000;"
+        "background-color:transparent;font-weight:400;font-style:normal;"
+        "font-variant:normal;text-decoration:none;vertical-align:baseline;"
+        'white-space:pre;white-space:pre-wrap;">'
+        f"{desc}"
+        '</span><span style="font-size:10.5pt;font-family:Roboto,sans-serif;'
+        "color:#000000;background-color:transparent;font-weight:400;"
+        "font-style:normal;font-variant:normal;text-decoration:none;"
+        "vertical-align:baseline;white-space:pre;white-space:pre-wrap;"
+        '"><span class="Apple-tab-span" style="white-space:pre;'
+        '"> </span></span><span style="font-size:10.5pt;'
+        "font-family:Roboto,sans-serif;color:#000000;"
+        "background-color:transparent;font-weight:400;font-style:normal;"
+        "font-variant:normal;text-decoration:none;vertical-align:baseline;"
+        'white-space:pre;white-space:pre-wrap;">'
+        f"{author}"
+        "</span></p>"
+    )
+    return template
+
+
+def create_temp_file(report_lines):
+    """Creates a temporary HTML file, writes lines to it, and opens it.
+
+    The file is created with a '.html' suffix and is not deleted automatically
+    on close (delete=False), allowing an external program ('open') to access it.
+
+    Args:
+        lines (list): A list of strings, where each string is a line of HTML
+                      content.
+    """
+    tmp = tempfile.NamedTemporaryFile(suffix=".html", delete=False)
+    with tmp as f:
+        f.write("\n".join(report_lines).encode())
+    subprocess.call(["open", tmp.name])
+
+
+def main():
+    headers = [
+        "CL List",
+        "Features",
+        "Bug Fixes",
+        "Docs",
+        "Data",
+        "CI / Infrastructure",
+    ]
+
+    new_branch, last_branch = find_branches()
+
+    commits = load_commits(new_branch, last_branch)
+    organized = organize_commits(commits, headers)
+
+    lines = []
+
+    for header, commits in organized.items():
+        lines.append(format_header(header))
+        for c in commits:
+            lines.append(format_commit(c))
+
+    create_temp_file(lines)
+
+
+if __name__ == "__main__":
+    main()
diff --git a/servo/tests/unit/test_release_notes_generator.py b/servo/tests/unit/test_release_notes_generator.py
new file mode 100644
index 0000000..665e0e8
--- /dev/null
+++ b/servo/tests/unit/test_release_notes_generator.py
@@ -0,0 +1,311 @@
+# Copyright 2025 The ChromiumOS Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+from collections import namedtuple
+import html as html_stdlib  # Alias to avoid conflict with pytest's html module
+
+import pytest
+
+# Assuming release_notes_generator.py is in the same directory or accessible via
+# PYTHONPATH
+from servo.dockerfiles.release_notes_generator import create_temp_file
+from servo.dockerfiles.release_notes_generator import find_branches
+from servo.dockerfiles.release_notes_generator import format_commit
+from servo.dockerfiles.release_notes_generator import format_header
+from servo.dockerfiles.release_notes_generator import handle_response
+from servo.dockerfiles.release_notes_generator import load_commits
+from servo.dockerfiles.release_notes_generator import organize_commits
+from servo.dockerfiles.release_notes_generator import organize_help
+
+
+# Define a Commit namedtuple similar to the one used in the script for test data
+Commit = namedtuple("Commit", ["hash", "desc", "author"])
+
+
+class TestFindBranches:
+    def test_find_branches_success(self, mocker):
+        """Tests find_branches successfully identifies and sorts branches."""
+        mock_output = (
+            "  origin/hdctools-release-0123-foo\n"  # year 23, month 01
+            "  origin/hdctools-release-1222-bar\n"  # year 22, month 12
+            "  origin/hdctools-release-0323-baz\n"  # year 23, month 03
+            "  origin/other-branch\n"
+        ).encode()
+        mock_check_output = mocker.patch(
+            "subprocess.check_output", return_value=mock_output
+        )
+
+        # Sort key is year + month, descending.
+        # "23" + "03" -> "2303"
+        # "23" + "01" -> "2301"
+        # "22" + "12" -> "2212"
+        # Expected order: origin/hdctools-release-0323-baz,
+        # origin/hdctools-release-0123-foo
+        expected_new_branch = "origin/hdctools-release-0323-baz"
+        expected_last_branch = "origin/hdctools-release-0123-foo"
+
+        new_branch, last_branch = find_branches()
+        assert new_branch == expected_new_branch
+        assert last_branch == expected_last_branch
+        mock_check_output.assert_called_once_with(["git", "branch", "-r"])
+
+    def test_find_branches_insufficient_matches(self, mocker):
+        """Tests find_branches raises IndexError if less than two matches."""
+        mock_output_one_match = "  origin/hdctools-release-0123-foo\n".encode()
+        mocker.patch("subprocess.check_output", return_value=mock_output_one_match)
+        with pytest.raises(IndexError):
+            find_branches()
+
+        mock_output_no_match = "  origin/other-branch\n".encode()
+        mocker.patch("subprocess.check_output", return_value=mock_output_no_match)
+        with pytest.raises(IndexError):
+            find_branches()
+
+    class TestLoadCommits:
+        DELIM = "␟"
+
+        def test_load_commits_success(self, mocker):
+            """Tests load_commits parses git log output correctly."""
+            mock_output = (
+                f"hash1{self.DELIM}Desc 1{self.DELIM}Author One\n"
+                f"hash2{self.DELIM}Desc 2{self.DELIM}Author Two\n"
+            ).encode()
+            mock_subprocess = mocker.patch(
+                "subprocess.check_output", return_value=mock_output
+            )
+
+            commits_list = load_commits("new_branch", "last_branch", cwd=".")
+
+            assert len(commits_list) == 2
+            assert commits_list[0] == Commit(
+                hash="hash1", desc="Desc 1", author="Author One"
+            )
+            assert commits_list[1] == Commit(
+                hash="hash2", desc="Desc 2", author="Author Two"
+            )
+
+            expected_args = [
+                "git",
+                "log",
+                f"--pretty=format:%H{self.DELIM}%s{self.DELIM}%aN",
+                "last_branch...new_branch",
+            ]
+            mock_subprocess.assert_called_once_with(expected_args, cwd=".")
+
+        def test_load_commits_no_output(self, mocker):
+            """Tests load_commits returns an empty list for no git log output."""
+            mocker.patch("subprocess.check_output", return_value=b"")
+            commits_list = load_commits("new", "old")
+            assert not commits_list
+
+    def test_organize_help_prints_correctly(self, capsys):
+        """Tests organize_help prints the expected help message."""
+        headers = ["Features", "Bug Fixes"]
+        organize_help(headers)
+        captured = capsys.readouterr()
+        expected_output = (
+            "Enter the number to organize the commit.\n0:Features\n1:Bug Fixes\n"
+        )
+        assert captured.out == expected_output
+
+    class TestHandleResponse:
+        HEADERS = ["Features", "Bug Fixes", "Docs"]
+
+        def test_valid_input(self, mocker):
+            mocker.patch("builtins.input", return_value="1")
+            response = handle_response(self.HEADERS)
+            assert response == "Bug Fixes"
+
+        def test_invalid_index_too_high(self, mocker, capsys):
+            mocker.patch("builtins.input", return_value="5")
+            response = handle_response(self.HEADERS)
+            assert response is None
+            assert "Invalid response: '5'" in capsys.readouterr().out
+
+        def test_invalid_index_negative(self, mocker, capsys):
+            mocker.patch("builtins.input", return_value="-1")
+            response = handle_response(self.HEADERS)
+            assert response is None
+            assert "Invalid response: '-1'" in capsys.readouterr().out
+
+        def test_non_numeric_input(self, mocker, capsys):
+            mocker.patch("builtins.input", return_value="abc")
+            response = handle_response(self.HEADERS)
+            assert response is None
+            assert "Invalid response: 'abc'" in capsys.readouterr().out
+
+        def test_keyboard_interrupt(self, mocker, capsys):
+            mocker.patch("builtins.input", side_effect=KeyboardInterrupt)
+            response = handle_response(self.HEADERS)
+            assert response is None
+            assert "Invalid response: Invalid" not in capsys.readouterr().out
+
+    class TestOrganizeCommits:
+        HEADERS = ["Features", "Fixes", "Chores"]
+        COMMITS_DATA = [
+            Commit(hash="h1", desc="Feature A", author="User1"),
+            Commit(hash="h2", desc="Fix B", author="User2"),
+            Commit(hash="h3", desc="Chore C", author="User1"),
+        ]
+
+        def test_basic_flow(self, mocker, capsys):
+            mock_org_help = mocker.patch(
+                "servo.dockerfiles.release_notes_generator.organize_help"
+            )
+            # Simulate user inputs: "Features", then "Fixes", then "Chores"
+            mock_handle_resp = mocker.patch(
+                "servo.dockerfiles.release_notes_generator.handle_response",
+                side_effect=["Features", "Fixes", "Chores"],
+            )
+
+            organized = organize_commits(self.COMMITS_DATA, self.HEADERS)
+
+            assert mock_org_help.call_count >= 1  # Called at least at the start
+            mock_org_help.assert_any_call(self.HEADERS)
+
+            assert mock_handle_resp.call_count == 3
+            for unused_header in self.HEADERS:
+                mock_handle_resp.assert_any_call(self.HEADERS)
+
+            captured = capsys.readouterr().out
+            assert f"{'Feature A': <60}\t(User1)" in captured
+            assert f"{'Fix B': <60}\t(User2)" in captured
+            assert f"{'Chore C': <60}\t(User1)" in captured
+
+            assert len(organized["Features"]) == 1
+            assert organized["Features"][0].desc == "Feature A"
+            assert len(organized["Fixes"]) == 1
+            assert organized["Fixes"][0].desc == "Fix B"
+            assert len(organized["Chores"]) == 1
+            assert organized["Chores"][0].desc == "Chore C"
+
+        def test_invalid_input_retry(
+            self, mocker, capsys
+        ):  # pylint: disable=unused-argument
+            mock_org_help = mocker.patch(
+                "servo.dockerfiles.release_notes_generator.organize_help"
+            )
+            # Simulate: invalid input, then valid "Features"
+            mock_handle_resp = mocker.patch(
+                "servo.dockerfiles.release_notes_generator.handle_response",
+                side_effect=[None, "Features"],
+            )
+            single_commit = [self.COMMITS_DATA[0]]
+
+            organized = organize_commits(single_commit, self.HEADERS)
+
+            # organize_help called at start, then again after invalid input
+            assert mock_org_help.call_count == 2
+            assert mock_handle_resp.call_count == 2
+
+            assert len(organized["Features"]) == 1
+            assert organized["Features"][0].desc == "Feature A"
+
+    def test_format_header_output(self):
+        """Tests the HTML output of format_header."""
+        name = "New Features & Updates"
+        escaped_name = html_stdlib.escape(name)
+        # This is brittle, but necessary if the exact HTML is required.
+        expected_html = (
+            "<br>"
+            '<p dir="ltr" style="line-height:1.38;margin-top:0pt;margin-bottom:0pt;'
+            '"><span style="font-size:10.5pt;font-family:Roboto,sans-serif;'
+            "color:#000000;background-color:transparent;font-weight:700;"
+            "font-style:normal;font-variant:normal;text-decoration:none;"
+            'vertical-align:baseline;white-space:pre;white-space:pre-wrap;">'
+            f"{escaped_name}"
+            '</span><span style="font-size:10.5pt;font-family:Roboto,sans-serif;'
+            "color:#000000;background-color:transparent;font-weight:400;"
+            "font-style:normal;font-variant:normal;text-decoration:none;"
+            "vertical-align:baseline;white-space:pre;white-space:pre-wrap;"
+            '"><span class="Apple-tab-span" style="white-space:pre;">'
+            ' </span></span><span style="font-size:10.5pt;'
+            "font-family:Roboto,sans-serif;color:#000000;"
+            "background-color:transparent;font-weight:400;font-style:normal;"
+            "font-variant:normal;text-decoration:none;vertical-align:baseline;"
+            'white-space:pre;white-space:pre-wrap;"><span class="Apple-tab-span" '
+            'style="white-space:pre;"> </span></span></p>'
+        )
+        assert format_header(name) == expected_html
+
+
+def test_format_commit_output():
+    """Tests the HTML output of format_commit."""
+    commit = Commit(
+        hash="abcdef123456",
+        desc="Implement cool feature & stuff",
+        author="Dev Person <dev@example.com>",
+    )
+
+    short_hash = html_stdlib.escape(commit.hash[:8])
+    url = html_stdlib.escape(
+        f"https://chromium-review.googlesource.com/q/{commit.hash}"
+    )
+    desc = html_stdlib.escape(commit.desc)
+    author_text = html_stdlib.escape(f"({commit.author})")
+
+    expected_html = (
+        '<p dir="ltr" style="line-height:1.38;margin-top:0pt;margin-bottom:0pt;'
+        '"><a href="'
+        f"{url}"
+        '" style="text-decoration:none;"><span style="font-size:9pt;'
+        "font-family:'Roboto Mono',monospace;color:#1155cc;"
+        "background-color:transparent;font-weight:400;font-style:normal;"
+        "font-variant:normal;text-decoration:underline;"
+        "-webkit-text-decoration-skip:none;text-decoration-skip-ink:none;"
+        'vertical-align:baseline;white-space:pre;white-space:pre-wrap;">'
+        f"{short_hash}"
+        '</span><span style="font-size:10.5pt;font-family:Roboto,sans-serif;'
+        "color:#000000;background-color:transparent;font-weight:400;"
+        "font-style:normal;font-variant:normal;text-decoration:none;"
+        "vertical-align:baseline;white-space:pre;white-space:pre-wrap;"
+        '"><span class="Apple-tab-span" style="white-space:pre;'
+        '"> </span></span></a><span style="font-size:10.5pt;'
+        "font-family:Roboto,sans-serif;color:#000000;"
+        "background-color:transparent;font-weight:400;font-style:normal;"
+        "font-variant:normal;text-decoration:none;vertical-align:baseline;"
+        'white-space:pre;white-space:pre-wrap;">'
+        f"{desc}"
+        '</span><span style="font-size:10.5pt;font-family:Roboto,sans-serif;'
+        "color:#000000;background-color:transparent;font-weight:400;"
+        "font-style:normal;font-variant:normal;text-decoration:none;"
+        "vertical-align:baseline;white-space:pre;white-space:pre-wrap;"
+        '"><span class="Apple-tab-span" style="white-space:pre;'
+        '"> </span></span><span style="font-size:10.5pt;'
+        "font-family:Roboto,sans-serif;color:#000000;"
+        "background-color:transparent;font-weight:400;font-style:normal;"
+        "font-variant:normal;text-decoration:none;vertical-align:baseline;"
+        'white-space:pre;white-space:pre-wrap;">'
+        f"{author_text}"
+        "</span></p>"
+    )
+    assert format_commit(commit) == expected_html
+
+
+def test_create_temp_file_functionality(mocker):
+    """Tests create_temp_file writes to a temp file and calls 'open'."""
+    mock_named_temp_file = mocker.patch("tempfile.NamedTemporaryFile")
+    mock_subprocess_call = mocker.patch("subprocess.call")
+
+    # Mock the file object returned by NamedTemporaryFile
+    mock_file_obj = mocker.MagicMock()
+    mock_file_context_manager = mocker.MagicMock()
+    mock_file_context_manager.__enter__.return_value = mock_file_obj
+    mock_file_context_manager.name = "placeholder_temp_file.html"
+    mock_named_temp_file.return_value = mock_file_context_manager
+
+    report_lines = ["<h1>Title</h1>", "<p>Content</p>"]
+    create_temp_file(report_lines)
+
+    mock_named_temp_file.assert_called_once_with(suffix=".html", delete=False)
+    mock_file_obj.write.assert_called_once_with(
+        "<h1>Title</h1>\n<p>Content</p>".encode()
+    )
+    mock_subprocess_call.assert_called_once_with(["open", "placeholder_temp_file.html"])
+
+    # Note: The main execution block of release_notes_generator.py is not unit tested
+    # here, as it involves direct calls to these functions in sequence.
+    # Testing each function individually provides good coverage.
+    # An integration test could be written for the main block if desired.# In
+    # release_notes_generator.py