Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .github/workflows/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 4 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}"
Expand Down
40 changes: 40 additions & 0 deletions scripts/changelog_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
20 changes: 20 additions & 0 deletions tests/test_changelog_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
[
Expand Down