diff --git a/.github/workflows/README.md b/.github/workflows/README.md index 41aff02b1..6e4984e98 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -75,6 +75,17 @@ Every user-facing PR needs a `CHANGELOG.md` bullet under `## [Unreleased]`. Dire `dev` or `main` are checked too, so user-facing commits without an associated PR still need a curated changelog entry. +**Skipping the gate for non-user-facing work.** The gate flags any change under `src/`, +`installer/`, or `soundpacks/` (the generated `weather_gov_api_client/` client is excluded). When a +PR is purely internal — refactors, CI, tooling, release plumbing — you have two escape hatches: + +- **PR:** add the `skip-changelog` label. The `Check CHANGELOG entry` step is skipped entirely. +- **Direct push:** put `Changelog: none` (or `[skip changelog]`) in the commit message. The gate + passes only when *every* non-merge commit in the range carries the marker, so a marker can't + silently exempt a change set that also contains user-facing work. + +Note: `.github/`, `tests/`, and `docs/` are never gated, so CI and test-only changes need no marker. + Nightly release notes include only newly added Unreleased entries since the previous nightly tag. Stable release notes use the matching version section, such as `## [0.6.1]`, and fall back to Unreleased only when a version section has not been cut yet. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 35497e685..d5b5a459e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -105,7 +105,10 @@ jobs: pip install --no-deps -e . - name: Check CHANGELOG entry - if: matrix.primary + # Skipped when a PR carries the `skip-changelog` label. For non-PR events + # the labels expression is null (contains() returns false), so pushes + # still run the gate and rely on a `Changelog: none` commit trailer. + if: matrix.primary && !contains(github.event.pull_request.labels.*.name, 'skip-changelog') run: | if [ "${{ github.event_name }}" = "pull_request" ]; then BASE="origin/${{ github.base_ref }}" diff --git a/scripts/changelog_tools.py b/scripts/changelog_tools.py index e49eece1e..8b9e3d77e 100644 --- a/scripts/changelog_tools.py +++ b/scripts/changelog_tools.py @@ -13,12 +13,21 @@ "installer/", "soundpacks/", ) +# Paths that ship inside the build surface but never warrant a release note. +# Checked before USER_FACING_PATH_PREFIXES, so these win even though they live +# under src/. Extend this as recurring false positives show up. +EXCLUDED_PATH_PREFIXES = ( + "src/accessiweather/weather_gov_api_client/", # generated NWS API client +) USER_FACING_PATHS = { "accessiweather.spec", "pyproject.toml", "scripts/generate_build_meta.py", } USER_FACING_SUFFIXES = (".spec",) +# Markers a commit message can carry to opt its change set out of the gate. +# Used for direct pushes, where there is no PR label to apply. +SKIP_CHANGELOG_MARKERS = ("changelog: none", "[skip changelog]") SECTION_ORDER = ("Added", "Changed", "Fixed", "Improved", "Removed", "Deprecated", "Security") PYPROJECT_METADATA_FIELDS_WITHOUT_CHANGELOG = {"version", "description"} PYPROJECT_TOOLING_REQUIREMENTS_WITHOUT_CHANGELOG = {"pyright", "ruff"} @@ -36,6 +45,8 @@ def run_git(args: list[str]) -> str: def is_user_facing_path(path: str) -> bool: normalized = path.replace("\\", "/") + if normalized.startswith(EXCLUDED_PATH_PREFIXES): + return False return ( normalized in USER_FACING_PATHS or normalized.endswith(USER_FACING_SUFFIXES) @@ -81,6 +92,31 @@ def changed_files(base: str, head: str) -> list[str]: return [line for line in output.splitlines() if line] +def messages_opt_out_of_changelog(messages: list[str]) -> bool: + """ + Return True only when every commit message opts out of the gate. + + Requiring all commits (rather than any) prevents a single skip marker from + silently exempting a change set that also contains user-facing work. + """ + if not messages: + return False + return all( + any(marker in message.casefold() for marker in SKIP_CHANGELOG_MARKERS) + for message in messages + ) + + +def commit_messages(base: str, head: str) -> list[str]: + log = run_git(["log", "--no-merges", "--format=%H", f"{base}..{head}"]) + hashes = [line for line in log.splitlines() if line] + return [run_git(["show", "-s", "--format=%B", commit]) for commit in hashes] + + +def commits_opt_out_of_changelog(base: str, head: str) -> bool: + return messages_opt_out_of_changelog(commit_messages(base, head)) + + def unreleased_added_entries(base: str, head: str) -> list[str]: base_entries = { entry @@ -217,6 +253,10 @@ def check_command(args: argparse.Namespace) -> int: print("No user-facing paths changed.") return 0 + if commits_opt_out_of_changelog(args.base, args.head): + print("All commits opt out of the changelog gate via a skip marker.") + return 0 + if CHANGELOG_PATH.as_posix() not in files: print("User-facing paths changed without updating CHANGELOG.md:", file=sys.stderr) for path in user_facing: diff --git a/tests/test_changelog_tools.py b/tests/test_changelog_tools.py index 1093245bb..2b077b892 100644 --- a/tests/test_changelog_tools.py +++ b/tests/test_changelog_tools.py @@ -5,6 +5,7 @@ extract_release_block, format_sections, is_user_facing_path, + messages_opt_out_of_changelog, normalize_entry, parse_sections, pyproject_changed_lines_require_changelog, @@ -72,6 +73,25 @@ def test_user_facing_paths_match_release_build_surface() -> None: assert not is_user_facing_path("tests/test_app.py") +def test_generated_api_client_is_not_user_facing() -> None: + assert not is_user_facing_path("src/accessiweather/weather_gov_api_client/models/alert.py") + # A real app module under src/ stays user-facing. + assert is_user_facing_path("src/accessiweather/weather_client.py") + + +def test_skip_marker_opts_out_only_when_all_commits_carry_it() -> None: + assert messages_opt_out_of_changelog(["chore: tidy logging\n\nChangelog: none"]) + assert messages_opt_out_of_changelog( + ["ci: bump action [skip changelog]", "test: add case\n\nchangelog: none"] + ) + # Mixed: one commit opts out, another does not -> gate still applies. + assert not messages_opt_out_of_changelog( + ["fix: real user-facing fix", "chore: cleanup\n\nChangelog: none"] + ) + # No commits (e.g. empty range) is not an opt-out. + assert not messages_opt_out_of_changelog([]) + + def test_pyproject_metadata_only_changes_do_not_need_changelog() -> None: assert not pyproject_changed_lines_require_changelog( [