Repository: beeware/briefcase

Issue:
Please accept CHANGELOG.md name
### Describe the bug

Please accept CHANGELOG.md name.
The .md extensions allows a nice rendering on GitHub and similar websites.

It is also a recommended name in [keepachangelog.com spec](https://keepachangelog.com/en/1.1.0/#frequently-asked-questions).

### Steps to reproduce

On Linux run `briefcase build` in a project which has `CHANGELOG.md` (instead of `CHANGELOG`) in the project root directory.
You'll get the following error:

```
Installing license... done
Installing changelog... errored

Your project does not contain a CHANGELOG file.

Create a file named `CHANGELOG` in the same directory as your `pyproject.toml`
with details about the release.
```

and the build will stop.



### Expected behavior

The build should find `CHANGELOG.md` and complete successfully.

My current workaround is copying/symlinking the expected name, but I'd rather avoid it.

Thanks for considering this.

### Screenshots

_No response_

### Environment

- Operating System: Linux (Fedora 41)
- Python version: 3.13.1
- Software versions:
  - Briefcase: 0.3.20


### Logs

_No response_

### Additional context

_No response_

Hints from issue comments:
@freakboy3742: The Changelog is used for both the RPM and PKG.ZIP formats; in the RPM case, the file just needs to be available for reading; for PKG.ZIP, the file is copied, but the filename can be configured. Since there's no hard-requirement for a file named CHANGELOG, it would make sense to support as many possible candidate filenames for changelog data.

The keepachangelog.com README also suggests `HISTORY`, `NEWS` and `RELEASES` as possible names; it would make sense to me support all those options, in `.md`, `.rst`, `.txt` and "bare no-extension" formats, and whichever one is found first get used. 

@JMah007: Hey, so just to clarify would you like me to modify it so that it looks for all these possible names (CHANGELOG, HISTORY, NEWS and RELEASES with extensions like .md, .rst and .txt) in some sort of loop perhaps?@freakboy3742: That's correct - any combination of `CHANGELOG`, `HISTORY`, `NEWS` and `RELEASES` with an extension of like `.md`, `.rst`, `.txt` or *no* extension should be considered valid input, and shouldn't raise a warning during build.

There is also code in the `package` step (i.e., when you produce an rpm/deb) that currently expects a file with the specific name `CHANGELOG` exists - that's why the warning is raised during build. That should accept any valid combination as input; however, the format itself may *require* that the file has a specific name for packaging purposes. You may need to do some research/testing into the RPM/DEB/PKG formats to confirm what is legal.

The one past piece is in the `convert` command; that will look for a file named `CHANGELOG` and produce a changelog file if one doesn't exist. That code can be adapted to accept any valid filename combination.@JMah007: I see, I will do some research into that. So should i be modifying the checking of CHANGELOG formats in the "convert" file as well as the "system" file under linux where there is a build and package step specific to linux?

On a side note, when I create an app using briefcase and "briefcase run" it in linux (Ubuntu wsl) but I get an error saying there is an error running the app due to missing "toga" module. Is there a reason for why toga isnt already installed?@freakboy3742: There's a couple of possible causes for an error like that - some of which are specific to the oddities of WSL as a platform. However, it's difficult to answer for certain without a full trace of the error you're seeing. 

This is a separate issue to this ticket though; so if you need help diagnosing an issue running under WSL, open a separate discussion topic, or drop into our Discord.@JMah007: So the convert.py will accept any of the name combinations. The build will also accept any of the combinations. The package will filter out combinations that are not allowed for that package type? Just want to understand this before I carry on. Thanks@freakboy3742: > So the convert.py will accept any of the name combinations. The build will also accept any of the combinations. 

Correct

> The package will filter out combinations that are not allowed for that package type? 

Not quite. Consider it a version of Postel's law - be liberal in what you accept, conservative in what you produce - but moderated slightly by the packaging tool's actual requirements.

So, as an example - if RPM format requires a file literally named `CHANGELOG`, then that is the file that must be produced by the package command - but any "changelog-like" file, whatever it is named, can be provided as input to that process. My `RELEASES.rst` file would be renamed `CHANGELOG` for the purposes of RPM distribution (we're not changing the name of the source file - we're copying the source file into a new filename in the build directory)

However, if RPM allows *any* filename, then the user-supplied name should be preserved.

Off the top of my head, I don't know exactly what RPM (or DEB, or PKG.ZIP) *actually* require - investigating those requirements is part of the task here.

Solution patch:
diff --git a/changes/2116.feature.rst b/changes/2116.feature.rst
new file mode 100644
index 000000000..c0431f464
--- /dev/null
+++ b/changes/2116.feature.rst
@@ -0,0 +1 @@
+Briefcase now supports the use of ``HISTORY``, ``NEWS`` and ``RELEASES`` as filenames for the change log of a project (in addition to ``CHANGELOG``). It also supports the use of ``.md``, ``.rst`` and ``.txt`` extensions on those files.
diff --git a/src/briefcase/commands/convert.py b/src/briefcase/commands/convert.py
index 2fd3d76c9..3b35d9166 100644
--- a/src/briefcase/commands/convert.py
+++ b/src/briefcase/commands/convert.py
@@ -669,13 +669,16 @@ def migrate_necessary_files(self, project_dir, test_source_dir, module_name):
             )
             copy2(project_dir / "LICENSE", self.base_path / "LICENSE")
 
-        # Copy changelog file
-        changelog_file = self.base_path / "CHANGELOG"
-        if not changelog_file.is_file():
+        # See if changefile exists
+        changelog = find_changelog_filename(self.base_path)
+
+        if changelog is None:
             self.console.warning(
-                f"\nChangelog file not found in '{self.base_path}'. You should either "
-                f"create a new '{self.base_path / 'CHANGELOG'}' file, or rename an "
-                "already existing changelog file to 'CHANGELOG'."
+                f"\nChangelog file not found in {self.base_path!r}. You should either "
+                f"create a new changelog file in {self.base_path!r}, or rename an "
+                "existing file to a known changelog file name (one of 'CHANGELOG', "
+                "'HISTORY', 'NEWS' or 'RELEASES'; the file may have an extension of "
+                "'.md', '.rst', '.txt', or have no extension)"
             )
 
         # Copy tests or test entry script
@@ -794,3 +797,24 @@ def __call__(
                 project_overrides=parse_project_overrides(project_overrides),
                 **options,
             )
+
+
+def find_changelog_filename(base_path):
+    """Find a valid changelog file in a given directory.
+
+    :param base_path: The directory to search
+    :returns: The filename of the changelog that was found; None if a changelog
+        with a known name could not be found.
+    """
+    changelog_formats = [
+        f"{name}{extension}"
+        for name in ["CHANGELOG", "HISTORY", "NEWS", "RELEASES"]
+        for extension in ["", ".md", ".rst", ".txt"]
+    ]
+
+    # Stops checking once a match is found
+    for format in changelog_formats:
+        changelog = base_path / format
+        if changelog.is_file():
+            return format
+    return None
diff --git a/src/briefcase/platforms/linux/system.py b/src/briefcase/platforms/linux/system.py
index d01af870e..a1fe5357c 100644
--- a/src/briefcase/platforms/linux/system.py
+++ b/src/briefcase/platforms/linux/system.py
@@ -14,6 +14,7 @@
     RunCommand,
     UpdateCommand,
 )
+from briefcase.commands.convert import find_changelog_filename
 from briefcase.config import AppConfig
 from briefcase.exceptions import BriefcaseCommandError, UnsupportedHostError
 from briefcase.integrations.docker import Docker, DockerAppContext
@@ -760,24 +761,26 @@ def build_app(self, app: AppConfig, **kwargs):
                 )
 
         with self.console.wait_bar("Installing changelog..."):
-            changelog = self.base_path / "CHANGELOG"
-            if changelog.is_file():
-                with changelog.open(encoding="utf-8") as infile:
-                    outfile = gzip.GzipFile(
-                        doc_folder / "changelog.gz", mode="wb", mtime=0
-                    )
-                    outfile.write(infile.read().encode("utf-8"))
-                    outfile.close()
-            else:
+            changelog = find_changelog_filename(self.base_path)
+
+            if changelog is None:
                 raise BriefcaseCommandError(
                     """\
-Your project does not contain a CHANGELOG file.
-
-Create a file named `CHANGELOG` in the same directory as your `pyproject.toml`
-with details about the release.
+Your project does not contain a changelog file with a known file name. You
+must provided a changelog file in the same directory as your `pyproject.toml`,
+with a known changelog file name (one of 'CHANGELOG', 'HISTORY', 'NEWS' or
+'RELEASES'; the file may have an extension of '.md', '.rst', or '.txt', or have
+no extension).
 """
                 )
 
+            changelog_source = self.base_path / changelog
+
+            with changelog_source.open(encoding="utf-8") as infile:
+                outfile = gzip.GzipFile(doc_folder / "changelog.gz", mode="wb", mtime=0)
+                outfile.write(infile.read().encode("utf-8"))
+                outfile.close()
+
         # Make a folder for manpages
         man_folder = (
             self.bundle_path(app)
@@ -1141,16 +1144,21 @@ def _package_rpm(self, app: AppConfig, **kwargs):  # pragma: no-cover-if-is-wind
 
                 # Add the changelog content to the bottom of the spec file.
                 f.write("\n%changelog\n")
-                changelog_source = self.base_path / "CHANGELOG"
-                if not changelog_source.is_file():
+                changelog = find_changelog_filename(self.base_path)
+
+                if changelog is None:
                     raise BriefcaseCommandError(
                         """\
-Your project does not contain a CHANGELOG file.
-
-Create a file named `CHANGELOG` in the same directory as your `pyproject.toml`
-with details about the release.
+Your project does not contain a changelog file with a known file name. You
+must provided a changelog file in the same directory as your `pyproject.toml`,
+with a known changelog file name (one of 'CHANGELOG', 'HISTORY', 'NEWS' or
+'RELEASES'; the file may have an extension of '.md', '.rst', or '.txt', or have
+no extension).
 """
                     )
+
+                changelog_source = self.base_path / changelog
+
                 with changelog_source.open(encoding="utf-8") as c:
                     f.write(c.read())
 
@@ -1200,17 +1208,21 @@ def _package_pkg(self, app: AppConfig, **kwargs):  # pragma: no-cover-if-is-wind
                 "App configuration does not define `description`. "
                 "Arch projects require a description."
             )
-        # The changelog should exist.
-        changelog_source = self.base_path / "CHANGELOG"
-        if not changelog_source.is_file():
+
+        changelog = find_changelog_filename(self.base_path)
+
+        if changelog is None:
             raise BriefcaseCommandError(
                 """\
-Your project does not contain a CHANGELOG file.
-Create a file named `CHANGELOG` in the same directory as your `pyproject.toml`
+Your project does not contain a changelog file with a valid file name.
+Create a changelog file with the following as its name (CHANGELOG, HISTORY, NEWS or RELEASES)
+with extensions (.md, .rst, .txt or no extension) in the same directory as your `pyproject.toml`
 with details about the release.
 """
             )
 
+        changelog_source = self.base_path / changelog
+
         # Generate the pkgbuild layout
         pkgbuild_path = self.bundle_path(app) / "pkgbuild"
         with self.console.wait_bar("Generating pkgbuild layout..."):
@@ -1218,8 +1230,8 @@ def _package_pkg(self, app: AppConfig, **kwargs):  # pragma: no-cover-if-is-wind
                 self.tools.shutil.rmtree(pkgbuild_path)
             pkgbuild_path.mkdir(parents=True)
 
-            # Copy the CHANGELOG file to the pkgbuild_path so that it is visible to PKGBUILD
-            self.tools.shutil.copy(changelog_source, pkgbuild_path / "CHANGELOG")
+            # Copy the CHANGELOG file build_path so that it is visible to PKGBUILD
+            self.tools.shutil.copy(changelog_source, pkgbuild_path / changelog)
 
         # Build the source archive
         with self.console.wait_bar("Building source archive..."):


Test patch:
diff --git a/tests/commands/convert/test__find_changelog_filename.py b/tests/commands/convert/test__find_changelog_filename.py
new file mode 100644
index 000000000..797fb43ea
--- /dev/null
+++ b/tests/commands/convert/test__find_changelog_filename.py
@@ -0,0 +1,42 @@
+import pytest
+
+from briefcase.platforms.linux.system import find_changelog_filename
+
+from ...utils import create_file
+
+
+def test_no_changelog(tmp_path):
+    """Changelog file does not exist"""
+    assert find_changelog_filename(tmp_path) is None
+
+
+def test_no_valid_changelog(tmp_path):
+    """Changelog file exists but with invalid name format so its not accepted"""
+    changelog_file = tmp_path / "base_path/invalid_changelog_format"
+    create_file(changelog_file, "First App Changelog")
+    assert find_changelog_filename(tmp_path / "base_path") is None
+
+
+@pytest.mark.parametrize(
+    "changelog_filename",
+    [
+        f"{name}{extension}"
+        for name in ["CHANGELOG", "HISTORY", "NEWS", "RELEASES"]
+        for extension in ["", ".md", ".rst", ".txt"]
+    ],
+)
+def test_has_changelog(tmp_path, changelog_filename):
+    """Makes sure all formats are accepted"""
+    changelog_file = tmp_path / f"base_path/{changelog_filename}"
+    create_file(changelog_file, "First App Changelog")
+    assert find_changelog_filename(tmp_path / "base_path") == changelog_filename
+
+
+def test_multiple_changefile(tmp_path):
+    """If there's more than one changelog, only one is found."""
+
+    changelog_file1 = tmp_path / "base_path/NEWS.txt"
+    changelog_file2 = tmp_path / "base_path/CHANGELOG.md"
+    create_file(changelog_file1, "First App Changelog")
+    create_file(changelog_file2, "First App Changelog")
+    assert find_changelog_filename(tmp_path / "base_path") == "CHANGELOG.md"
diff --git a/tests/commands/convert/test_migrate_necessary_files.py b/tests/commands/convert/test_migrate_necessary_files.py
index b6a90a480..b7635ea55 100644
--- a/tests/commands/convert/test_migrate_necessary_files.py
+++ b/tests/commands/convert/test_migrate_necessary_files.py
@@ -189,18 +189,25 @@ def test_warning_without_changelog_file(
     )
 
     convert_command.console.warning.assert_called_once_with(
-        f"\nChangelog file not found in '{convert_command.base_path}'. You should either "
-        f"create a new '{convert_command.base_path / 'CHANGELOG'}' file, or rename an "
-        "already existing changelog file to 'CHANGELOG'."
+        f"\nChangelog file not found in {convert_command.base_path!r}. You should either "
+        f"create a new changelog file in {convert_command.base_path!r}, or rename an "
+        "existing file to a known changelog file name (one of 'CHANGELOG', "
+        "'HISTORY', 'NEWS' or 'RELEASES'; the file may have an extension of "
+        "'.md', '.rst', '.txt', or have no extension)"
     )
 
 
+@pytest.mark.parametrize(
+    "changelog_filename",
+    ["CHANGELOG", "NEWS.txt"],
+)
 @pytest.mark.parametrize("test_source_dir", ["tests"])
 def test_no_warning_with_license_and_changelog_file(
     convert_command,
     project_dir_with_files,
     dummy_app_name,
     test_source_dir,
+    changelog_filename,
 ):
     """No warning is raised if both license file and changelog file is present."""
     convert_command.console.warning = mock.MagicMock()
@@ -210,7 +217,7 @@ def test_no_warning_with_license_and_changelog_file(
         '[project]\nlicense = { file = "LICENSE.txt" }',
     )
     create_file(convert_command.base_path / "LICENSE.txt", "")
-    create_file(convert_command.base_path / "CHANGELOG", "")
+    create_file(convert_command.base_path / changelog_filename, "")
     convert_command.migrate_necessary_files(
         project_dir_with_files,
         test_source_dir,
@@ -240,9 +247,11 @@ def test_two_warnings_without_license_and_changelog_file(
         "Briefcase will create a template 'LICENSE' file."
     )
     changelog_warning = (
-        f"\nChangelog file not found in '{convert_command.base_path}'. You should either "
-        f"create a new '{convert_command.base_path / 'CHANGELOG'}' file, or rename an "
-        "already existing changelog file to 'CHANGELOG'."
+        f"\nChangelog file not found in {convert_command.base_path!r}. You should either "
+        f"create a new changelog file in {convert_command.base_path!r}, or rename an "
+        "existing file to a known changelog file name (one of 'CHANGELOG', "
+        "'HISTORY', 'NEWS' or 'RELEASES'; the file may have an extension of "
+        "'.md', '.rst', '.txt', or have no extension)"
     )
     assert convert_command.console.warning.mock_calls == [
         mock.call(license_warning),
diff --git a/tests/platforms/linux/system/test_build.py b/tests/platforms/linux/system/test_build.py
index 797e3c85b..cf8dbdaf2 100644
--- a/tests/platforms/linux/system/test_build.py
+++ b/tests/platforms/linux/system/test_build.py
@@ -225,7 +225,7 @@ def test_missing_changelog(build_command, first_app, tmp_path):
     # Build the app; it will fail
     with pytest.raises(
         BriefcaseCommandError,
-        match=r"Your project does not contain a CHANGELOG file.",
+        match=r"Your project does not contain a changelog file.",
     ):
         build_command.build_app(first_app)
 
diff --git a/tests/platforms/linux/system/test_package__pkg.py b/tests/platforms/linux/system/test_package__pkg.py
index 2a7ee5fe8..75c4a9ed0 100644
--- a/tests/platforms/linux/system/test_package__pkg.py
+++ b/tests/platforms/linux/system/test_package__pkg.py
@@ -146,16 +146,27 @@ def test_verify_docker(package_command, first_app_pkg, monkeypatch):
     makepkg.exists.assert_not_called()
 
 
+@pytest.mark.parametrize(
+    "changelog_filename",
+    ["HISTORY", "NEWS.txt"],
+)
 @pytest.mark.skipif(sys.platform == "win32", reason="Can't build PKGs on Windows")
-def test_pkg_package(package_command, first_app_pkg, tmp_path):
+def test_pkg_package(package_command, first_app_pkg, tmp_path, changelog_filename):
     """A pkg app can be packaged."""
     bundle_path = tmp_path / "base_path/build/first-app/somevendor/surprising"
 
+    # Remove CHANGELOG made in conftest.py and replace with another possible changelog format
+    base_path = tmp_path / "base_path"
+    old_changelog = base_path / "CHANGELOG"
+    new_changelog = base_path / changelog_filename
+    old_changelog.unlink()
+    create_file(new_changelog, "First App Changelog")
+
     # Package the app
     package_command.package_app(first_app_pkg)
 
     # The CHANGELOG file is copied
-    assert (bundle_path / "pkgbuild/CHANGELOG").exists()
+    assert (bundle_path / f"pkgbuild/{changelog_filename}").exists()
 
     # The PKGBUILD file is written
     assert (bundle_path / "pkgbuild/PKGBUILD").exists()
@@ -465,7 +476,7 @@ def test_no_changelog(package_command, first_app_pkg, tmp_path):
 
     # Package the app; this will fail
     with pytest.raises(
-        BriefcaseCommandError, match=r"Your project does not contain a CHANGELOG file."
+        BriefcaseCommandError, match=r"Your project does not contain a changelog file."
     ):
         package_command.package_app(first_app_pkg)
 
diff --git a/tests/platforms/linux/system/test_package__rpm.py b/tests/platforms/linux/system/test_package__rpm.py
index e897fa171..c1e3ccf22 100644
--- a/tests/platforms/linux/system/test_package__rpm.py
+++ b/tests/platforms/linux/system/test_package__rpm.py
@@ -146,11 +146,22 @@ def test_verify_docker(package_command, first_app_rpm, monkeypatch):
     rpmbuild.exists.assert_not_called()
 
 
+@pytest.mark.parametrize(
+    "changelog_filename",
+    ["HISTORY", "NEWS.txt"],
+)
 @pytest.mark.skipif(sys.platform == "win32", reason="Can't build RPMs on Windows")
-def test_rpm_package(package_command, first_app_rpm, tmp_path):
+def test_rpm_package(package_command, first_app_rpm, tmp_path, changelog_filename):
     """A rpm app can be packaged."""
     bundle_path = tmp_path / "base_path/build/first-app/somevendor/surprising"
 
+    # Remove CHANGELOG made in conftest.py and replace with another possible changelog format
+    base_path = tmp_path / "base_path"
+    old_changelog = base_path / "CHANGELOG"
+    new_changelog = base_path / changelog_filename
+    old_changelog.unlink()
+    create_file(new_changelog, "First App Changelog")
+
     # Package the app
     package_command.package_app(first_app_rpm)
 
@@ -619,7 +630,7 @@ def test_no_changelog(package_command, first_app_rpm, tmp_path):
 
     # Package the app; this will fail
     with pytest.raises(
-        BriefcaseCommandError, match=r"Your project does not contain a CHANGELOG file."
+        BriefcaseCommandError, match=r"Your project does not contain a changelog file."
     ):
         package_command.package_app(first_app_rpm)