Skip to content

Commit

Permalink
Validate Keep a Changelog format within GitHub Actions (#45)
Browse files Browse the repository at this point in the history
- Fix #41
  • Loading branch information
Archmonger authored Sep 28, 2024
1 parent 67075d5 commit 88d0232
Show file tree
Hide file tree
Showing 4 changed files with 202 additions and 26 deletions.
6 changes: 5 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,16 +49,20 @@ jobs:
with:
python-version: 3.x
cache: pip
- name: Install dependecies
- name: Install dependencies
run: |
pip install --upgrade hatch uv
# FIXME: Link checking temporarily disabled https://github.com/Archmonger/ServeStatic/issues/42
- name: Check documentation links
if: false
run: |
hatch run docs:linkcheck
- name: Check docs build
run: |
hatch run docs:build
- name: Validate changelog format
run: |
hatch run scripts/validate_changelog.py
test-python:
name: Python ${{ matrix.python-version }} on ${{ matrix.os }}
Expand Down
27 changes: 2 additions & 25 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,35 +2,12 @@

All notable changes to this project will be documented in this file.

<!--attr-start-->

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

<!--attr-end-->

<!--
Using the following categories, list your changes in this order:
### Added
- for new features.
### Changed
- for changes in existing functionality.
### Deprecated
- for soon-to-be removed features.
### Removed
- for removed features.
### Fixed
- for bug fixes.
### Security
- for vulnerability fixes.
-->

<!--changelog-start-->
[Added, Changed, Deprecated, Removed, Fixed, Security]
-->

## [Unreleased]

Expand Down
1 change: 1 addition & 0 deletions docs/src/contributing.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ By utilizing `hatch`, the following commands are available to manage the develop
| `hatch run docs:serve` | Start the [`mkdocs`](https://www.mkdocs.org/) server to view documentation locally |
| `hatch run docs:build` | Build the documentation |
| `hatch run docs:linkcheck` | Check for broken links in the documentation |
| `hatch run scripts\validate_changelog.py` | Check if the changelog meets the [Keep A Changelog](https://keepachangelog.com/en/1.1.0/) specification |

??? tip "Configure your IDE for linting"

Expand Down
194 changes: 194 additions & 0 deletions scripts/validate_changelog.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
"""Parses Keep a Changelog format and ensures it is valid"""

# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///

# ruff: noqa: PERF401
import re
import sys

GITHUB_COMPARE_URL_START_RE = r"https?://github.com/[^/]+/[^/]+/compare/"
GITHUB_COMPARE_URL_RE = GITHUB_COMPARE_URL_START_RE + r"([\w.]+)\.\.\.([\w.]+)"
GITHUB_RELEASE_TAG_URL_START_RE = r"https?://github.com/[^/]+/[^/]+/releases/tag/"
GITHUB_RELEASE_TAG_URL_RE = GITHUB_RELEASE_TAG_URL_START_RE + r"([\w.]+)"
UNRELEASED_HEADER = "## [Unreleased]\n"
VERSION_HEADER_START_RE = r"## \[([\w.]+)\]"
VERSION_HEADER_FULL_RE = VERSION_HEADER_START_RE + r" - \d{4}-\d{2}-\d{2}\n"
UNRELEASED_HYPERLINK_RE = r"\[Unreleased\]: " + GITHUB_COMPARE_URL_RE + r"\n"
VERSION_HYPERLINK_START_RE = r"\[([\w.]+)\]: "
VERSION_HYPERLINK_RE = VERSION_HYPERLINK_START_RE + GITHUB_COMPARE_URL_RE + r"\n"
INITIAL_VERSION_RE = VERSION_HYPERLINK_START_RE + GITHUB_RELEASE_TAG_URL_RE + r"\n"
SECTION_HEADER_RE = r"### ([^\n]+)\n"
HTML_COMMENT_RE = r"<!--.*?-->", re.DOTALL


def validate_changelog(changelog_path="CHANGELOG.md"):
errors = []
# Read the contents of the changelog file
with open(changelog_path, encoding="UTF-8") as file:
changelog = file.read()

# Remove HTML comments
changelog = re.sub(HTML_COMMENT_RE[0], "", changelog, flags=HTML_COMMENT_RE[1])
# Replace duplicate newlines with a single newline
changelog = re.sub(r"\n+", "\n", changelog)
# Replace duplicate spaces with a single space
changelog = re.sub(r" +", " ", changelog)

# Ensure `## [Unreleased]\n` is present
if changelog.find(UNRELEASED_HEADER) == -1:
errors.append("Changelog does contain '## [Unreleased]'")

# Ensure unreleased has a URL
unreleased_url = re.search(UNRELEASED_HYPERLINK_RE, changelog)
if unreleased_url is None:
errors.append("Unreleased does not have a URL")

# Ensure UNRELEASED_URL_REGEX ends in "HEAD"
if unreleased_url and unreleased_url[2] != "HEAD":
errors.append(
f"The hyperlink for [Unreleased] was expected to contain 'HEAD' but instead found '{unreleased_url[2]}'"
)

# Ensure the unreleased URL's version is the previous version (version text proceeding [Unreleased])
if unreleased_url:
previous_version_linked_in_unreleased = unreleased_url[1]
previous_version = re.search(r"\[([^\]]+)\] -", changelog)
if previous_version and previous_version[1] != previous_version_linked_in_unreleased:
errors.append(
f"The hyperlink for [Unreleased] was expected to contain '{previous_version[1]}' but instead found '{previous_version_linked_in_unreleased}'"
)

# Gather all version headers. Note that the 'Unreleased' hyperlink is validated separately.
versions_from_headers = re.findall(VERSION_HEADER_START_RE, changelog)
versions_from_headers = [header for header in versions_from_headers if header != "Unreleased"]

# Ensure each version header has a hyperlink
for version in versions_from_headers:
if re.search(VERSION_HYPERLINK_START_RE.replace(r"[\w.]+", version), changelog) is None:
errors.append(f"Version '{version}' does not have a URL")

# Gather all hyperlinks. Note that the 'Unreleased' hyperlink is validated separately
hyperlinks = re.findall(VERSION_HYPERLINK_RE, changelog)
hyperlinks = [hyperlink for hyperlink in hyperlinks if hyperlink[0] != "Unreleased"]

# Ensure each hyperlink has a header
for hyperlink in hyperlinks:
if hyperlink[0] not in versions_from_headers:
errors.append(f"Hyperlink '{hyperlink[0]}' does not have a version title '## [{hyperlink[0]}]'")

# Ensure there is only one initial version
initial_version = re.findall(INITIAL_VERSION_RE, changelog)
if len(initial_version) > 1:
errors.append(
"There is more than one link to a '.../releases/tag/' URL "
"when this is reserved for only the initial version."
)

# Ensure the initial version's tag name matches the version name
if initial_version:
initial_version_tag = initial_version[0][0]
if initial_version_tag != initial_version[0][1]:
errors.append(
f"The initial version tag name '{initial_version[0][1]}' does "
f"not match the version header '{initial_version[0][0]}'"
)

# Ensure the initial version has a header
if (
initial_version
and re.search(VERSION_HEADER_START_RE.replace(r"[\w.]+", initial_version[0][0]), changelog) is None
):
errors.append(f"Initial version '{initial_version[0][0]}' does not have a version header")

# Ensure all versions headers have dates
full_version_headers = re.findall(VERSION_HEADER_FULL_RE, changelog)
if len(full_version_headers) != len(versions_from_headers):
for version in versions_from_headers:
if re.search(VERSION_HEADER_FULL_RE.replace(r"([\w.]+)", rf"({version})"), changelog) is None:
errors.append(f"Version header '## [{version}]' does not have a date in the correct format")

# Ensure version links always diff to the previous version
versions_from_hyperlinks = [hyperlinks[0] for hyperlinks in hyperlinks]
versions_from_hyperlinks.append(initial_version[0][0])
for position, version in enumerate(versions_from_hyperlinks):
if position == len(versions_from_hyperlinks) - 1:
break

pattern = (
rf"\[{version}\]: {GITHUB_COMPARE_URL_START_RE}{versions_from_hyperlinks[position + 1]}\.\.\.{version}"
)
if re.search(pattern, changelog) is None:
errors.append(
f"Based on hyperlink order, the URL for version '{version}' was expected to contain '.../compare/{versions_from_hyperlinks[position + 1]}...{version}'"
)

# Ensure the versions in the headers are in descending order
for position, version in enumerate(versions_from_headers):
if position == len(versions_from_headers) - 1:
break

if version <= versions_from_headers[position + 1]:
errors.append(f"Version '{version}' should be listed before '{versions_from_headers[position + 1]}'")

# Ensure the order of versions from headers matches the hyperlinks
for position, version in enumerate(versions_from_headers):
if position == len(versions_from_headers) - 1:
break

if version != versions_from_hyperlinks[position]:
errors.append(
f"The order of the version headers does not match your hyperlinks. "
f"Found '{versions_from_hyperlinks[position]}' in hyperlinks but expected '{version}'"
)

# Check if the user is using something other than <Added||Changed||Deprecated||Removed||Fixed||Security>
section_headers = re.findall(SECTION_HEADER_RE, changelog)
for header in section_headers:
if header not in {"Added", "Changed", "Deprecated", "Removed", "Fixed", "Security"}:
errors.append(f"Using non-standard section header '{header}'")

# Check the order of the sections
# Simplify the changelog into a list of `##` and `###` headers
changelog_header_lines = [line for line in changelog.split("\n") if line.startswith(("###", "##"))]
order = ["### Added", "### Changed", "### Deprecated", "### Removed", "### Fixed", "### Security"]
current_position_in_order = -1
version_header = "UNKNOWN"
for _line in changelog_header_lines:
line = _line.strip()
# Reset current position if we are at a version header
if line.startswith("## "):
version_header = line
current_position_in_order = -1

# Check if the current section is in the correct order
if line in order:
section_position = order.index(line)
if section_position < current_position_in_order:
errors.append(
f"Section '{line}' is out of order in version '{version_header}'. "
"Expected section order: [Added, Changed, Deprecated, Removed, Fixed, Security]"
)
current_position_in_order = section_position

return errors


if __name__ == "__main__":
if len(sys.argv) == 2:
validate_changelog(sys.argv[1])
if len(sys.argv) > 2:
print("Usage: python validate_changelog.py [changelog_path]")
sys.exit(1)

errors = validate_changelog()
if errors:
print("Changelog has formatting errors! ❌")
for error in errors:
print(f" - {error}")
sys.exit(1)

print("Changelog is valid! ✅")
sys.exit(0)

0 comments on commit 88d0232

Please # to comment.