Repository: beeware/briefcase (python)

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_

Pull request description:
Accept other changelog name and extension #2116
This pull request fixes BUILD and PACKAGE in linux to accept changelog name and extension in any combination of CHANGELOG, HISTORY, NEWS and RELEASES with an extension of like .md, .rst, .txt or no extension.

Fixes #2116

## PR Checklist:
<!--- Go over all the following points, and put an `x` in all the boxes that apply. -->
- [x] All new features have been tested
- [x] All new features have been documented
- [x] I have read the **CONTRIBUTING.md** file
- [x] I will abide by the code of conduct


Patch:
diff --git a/changes/2116.feature.rst b/changes/2116.feature.rst
new file mode 100644
index 00000000..c0431f46
--- /dev/null
+++ b/changes/2116.feature.rst
@@ -0,0 +1,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 2fd3d76c..3b35d916 100644
--- a/src/briefcase/commands/convert.py
+++ b/src/briefcase/commands/convert.py
@@ -669,13 +669,16 @@ class ConvertCommand(NewCommand):
             )
             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 @@ To run your application, type:
                 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 d01af870..a1fe5357 100644
--- a/src/briefcase/platforms/linux/system.py
+++ b/src/briefcase/platforms/linux/system.py
@@ -14,6 +14,7 @@ from briefcase.commands import (
     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 @@ app's configuration.
                 )
 
         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 @@ class LinuxSystemPackageCommand(LinuxSystemMixin, PackageCommand):
 
                 # 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 @@ with details about the release.
                 "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 @@ with details about the release.
                 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 00000000..797fb43e
--- /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 b6a90a48..b7635ea5 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 797e3c85..cf8dbdaf 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 2a7ee5fe..75c4a9ed 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 e897fa17..c1e3ccf2 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)