From 1194731e5b5bc10a14307daf5c3dc088fe51f109 Mon Sep 17 00:00:00 2001 From: Nicholas Chammas Date: Wed, 15 Jul 2026 21:07:25 -0400 Subject: [PATCH 1/5] add linter for release notes --- .dev/lint-release-notes.py | 160 +++++++++++++++++++++++++++++++++++++ .github/workflows/lint.yml | 14 ++++ 2 files changed, 174 insertions(+) create mode 100644 .dev/lint-release-notes.py create mode 100644 .github/workflows/lint.yml diff --git a/.dev/lint-release-notes.py b/.dev/lint-release-notes.py new file mode 100644 index 00000000000..b486df70a69 --- /dev/null +++ b/.dev/lint-release-notes.py @@ -0,0 +1,160 @@ +""" +Lint release notes under releases/_posts/ for common issues. +""" + +import re +import sys +from dataclasses import dataclass +from datetime import date +from pathlib import Path + +RELEASE_POSTS_DIR = Path(__file__).parent.parent / "releases" / "_posts" +FILENAME_RE = re.compile( + r"^(?P\d{4}-\d{2}-\d{2})-spark-release-(?P.+)\.md$" +) + + +@dataclass(frozen=True) +class Version: + major: int + minor: int + maintenance: int + + +def parse_date(date_str: str) -> date: + return date.fromisoformat(date_str) + + +def parse_version(version: str) -> Version: + parts = version.split("-") + if len(parts) != 3: + raise ValueError(f"Expected 3 parts, got {len(parts)}") + major, minor, maintenance = (int(p) for p in parts) + return Version(major, minor, maintenance) + + +def check_filename_format(name: str): + m = FILENAME_RE.match(name) + if not m: + raise ValueError( + "filename does not match expected pattern. " + "Valid example: 2025-12-16-spark-release-4-1-0.md" + ) + return m + + +def check_date_format(date_str: str) -> date: + try: + return parse_date(date_str) + except ValueError: + raise ValueError( + f"date is not valid ISO format (YYYY-MM-DD). Valid example: 2025-12-16" + ) + + +def check_version_format(version_str: str) -> Version: + try: + return parse_version(version_str) + except ValueError: + raise ValueError( + f"version '{version_str}' is malformed. Valid example: 4-1-0" + ) + + +def check_highlights(path: Path, version: Version) -> None: + if version.maintenance == 0: + content = path.read_text(encoding="utf-8") + if not re.search(r"## .*Highlights\b", content, re.MULTILINE): + raise ValueError( + "major/minor release is missing a highlights heading. Add: ## Highlights" + ) + + +def lint_file(path: Path) -> list[str]: + errors: list[str] = [] + name = path.name + + try: + m = check_filename_format(name) + except ValueError as e: + errors.append(f"{name}: {e}") + return errors + + try: + release_date = check_date_format(m.group("date")) + except ValueError as e: + errors.append(f"{name}: {e}") + return errors + + if release_date.year < 2025: + return errors + + try: + version = check_version_format(m.group("version")) + except ValueError as e: + errors.append(f"{name}: {e}") + return errors + + try: + check_highlights(path, version) + except ValueError as e: + errors.append(f"{name}: {e}") + + return errors + + +def test(): + import tempfile + + with tempfile.TemporaryDirectory() as tmpdir: + # Bad filename format + bad_name = Path(tmpdir) / "spark-release-1-0-0.md" + bad_name.write_text("---\n---\n\n## Highlights\n") + + # Good file: 2025+ major/minor with highlights + good_major_release = Path(tmpdir) / "2025-01-01-spark-release-1-0-0.md" + good_major_release.write_text("---\n---\n\n## Highlights\nSome highlights here.\n") + + # Bad file: 2025+ major/minor without highlights + bad_major_release = Path(tmpdir) / "2026-01-01-spark-release-1-0-0.md" + bad_major_release.write_text("---\n---\n\nNo highlights here.\n") + + # Patch release: no highlights required + good_maintenance_release = Path(tmpdir) / "2025-01-01-spark-release-1-0-1.md" + good_maintenance_release.write_text("---\n---\n\nNo highlights required.\n") + + bad_name_errors = lint_file(bad_name) + good_major_release_errors = lint_file(good_major_release) + bad_major_release_errors = lint_file(bad_major_release) + good_maintenance_release_errors = lint_file(good_maintenance_release) + + assert len(bad_name_errors) == 1, ( + f"Expected 1 error for bad filename, got {len(bad_name_errors)}: " + f"{bad_name_errors}" + ) + assert "filename does not match expected pattern" in bad_name_errors[0] + assert good_major_release_errors == [], \ + f"Expected no errors for good file, got: {good_major_release_errors}" + assert len(bad_major_release_errors) == 1, \ + "Expected 1 error for bad file, " \ + f"got {len(bad_major_release_errors)}: {bad_major_release_errors}" + assert "highlights" in bad_major_release_errors[0].lower() + assert good_maintenance_release_errors == [], \ + f"Expected no errors for patch file, got: {good_maintenance_release_errors}" + + +def main() -> int: + all_errors: list[str] = [] + for path in sorted(RELEASE_POSTS_DIR.glob("*.md")): + all_errors.extend(lint_file(path)) + if all_errors: + for error in all_errors: + print(f"ERROR: {error}", file=sys.stderr) + return 1 + print("All release notes passed linting.") + return 0 + + +if __name__ == "__main__": + test() + sys.exit(main()) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 00000000000..a57423d4f65 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,14 @@ +name: Lint + +on: pull_request + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v6 + with: + python-version: "3.13" + - name: Lint release notes + run: python3 .dev/lint-release-notes.py From 868aec379208f936a9bafecfe8cb7b47e5c8ea13 Mon Sep 17 00:00:00 2001 From: Nicholas Chammas Date: Wed, 15 Jul 2026 21:42:28 -0400 Subject: [PATCH 2/5] fix file name format --- ...6-spark-release-4.1.0.md => 2025-12-16-spark-release-4-1-0.md} | 0 ...9-spark-release-4.1.1.md => 2026-01-09-spark-release-4-1-1.md} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename releases/_posts/{2025-12-16-spark-release-4.1.0.md => 2025-12-16-spark-release-4-1-0.md} (100%) rename releases/_posts/{2026-01-09-spark-release-4.1.1.md => 2026-01-09-spark-release-4-1-1.md} (100%) diff --git a/releases/_posts/2025-12-16-spark-release-4.1.0.md b/releases/_posts/2025-12-16-spark-release-4-1-0.md similarity index 100% rename from releases/_posts/2025-12-16-spark-release-4.1.0.md rename to releases/_posts/2025-12-16-spark-release-4-1-0.md diff --git a/releases/_posts/2026-01-09-spark-release-4.1.1.md b/releases/_posts/2026-01-09-spark-release-4-1-1.md similarity index 100% rename from releases/_posts/2026-01-09-spark-release-4.1.1.md rename to releases/_posts/2026-01-09-spark-release-4-1-1.md From 7d75e553999eec3eb3625b0b45eee5ee6dd2adf0 Mon Sep 17 00:00:00 2001 From: Nicholas Chammas Date: Wed, 15 Jul 2026 21:42:39 -0400 Subject: [PATCH 3/5] tweak highlights regex --- .dev/lint-release-notes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.dev/lint-release-notes.py b/.dev/lint-release-notes.py index b486df70a69..b9a878e554c 100644 --- a/.dev/lint-release-notes.py +++ b/.dev/lint-release-notes.py @@ -64,7 +64,7 @@ def check_version_format(version_str: str) -> Version: def check_highlights(path: Path, version: Version) -> None: if version.maintenance == 0: content = path.read_text(encoding="utf-8") - if not re.search(r"## .*Highlights\b", content, re.MULTILINE): + if not re.search(r"^#?## .*Highlights\b", content, re.MULTILINE): raise ValueError( "major/minor release is missing a highlights heading. Add: ## Highlights" ) From f6a8d1ea700228ebab7b0fad765afd18981ad410 Mon Sep 17 00:00:00 2001 From: Nicholas Chammas Date: Wed, 15 Jul 2026 22:30:51 -0400 Subject: [PATCH 4/5] add html changes --- .../{spark-release-4.1.0.html => spark-release-4-1-0.html} | 0 .../{spark-release-4.1.1.html => spark-release-4-1-1.html} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename site/releases/{spark-release-4.1.0.html => spark-release-4-1-0.html} (100%) rename site/releases/{spark-release-4.1.1.html => spark-release-4-1-1.html} (100%) diff --git a/site/releases/spark-release-4.1.0.html b/site/releases/spark-release-4-1-0.html similarity index 100% rename from site/releases/spark-release-4.1.0.html rename to site/releases/spark-release-4-1-0.html diff --git a/site/releases/spark-release-4.1.1.html b/site/releases/spark-release-4-1-1.html similarity index 100% rename from site/releases/spark-release-4.1.1.html rename to site/releases/spark-release-4-1-1.html From 5446d3e10d64994175dbab1d5aef923ea076a04c Mon Sep 17 00:00:00 2001 From: Nicholas Chammas Date: Thu, 16 Jul 2026 09:19:13 -0400 Subject: [PATCH 5/5] give each linter its own name and job --- .github/workflows/lint.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index a57423d4f65..2bcda3009ab 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -3,7 +3,8 @@ name: Lint on: pull_request jobs: - lint: + release-notes: + name: Release Notes runs-on: ubuntu-latest steps: - uses: actions/checkout@v7