From 0e2e719057cb94e5bfad171efda62563a7b07357 Mon Sep 17 00:00:00 2001 From: Tomas Pereira de Vasconcelos Date: Fri, 24 Jul 2026 19:34:35 +0200 Subject: [PATCH 01/13] Automate bot PR maintenance: weekly grouped updates, skip-news labeling, auto-merge --- .github/dependabot.yml | 23 +++++++++--- .github/workflows/bot-prs.yml | 54 +++++++++++++++++++++++++++++ cicd_utils/find-unmentioned-prs.sh | 9 +++++ docs/development/release_process.md | 2 +- 4 files changed, 83 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/bot-prs.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml index fe105d09..5c673748 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,5 +1,14 @@ # Dependabot configuration # +# Notes: +# - Updates are grouped into a single weekly PR per ecosystem to reduce +# review noise and avoid changelog merge conflicts between bot PRs. +# - The `skip news` label exempts bot PRs from the changelog check +# (see .github/workflows/check-release-notes.yml). Their changelog +# entries are added in bulk at release time with the help of the +# ./cicd_utils/find-unmentioned-prs.sh script. +# - Bot PRs are auto-merged once approved (see .github/workflows/bot-prs.yml). +# # References: # - https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file # @@ -8,10 +17,16 @@ updates: - package-ecosystem: "github-actions" directory: "/" schedule: - interval: "daily" - labels: [ "github_actions" ] + interval: "weekly" + labels: [ "github_actions", "skip news" ] + groups: + github-actions: + patterns: [ "*" ] - package-ecosystem: "pip" directory: "/" schedule: - interval: "daily" - labels: [ "dependencies" ] + interval: "weekly" + labels: [ "dependencies", "skip news" ] + groups: + pip: + patterns: [ "*" ] diff --git a/.github/workflows/bot-prs.yml b/.github/workflows/bot-prs.yml new file mode 100644 index 00000000..7135a623 --- /dev/null +++ b/.github/workflows/bot-prs.yml @@ -0,0 +1,54 @@ +# Automation for PRs opened by trusted bots (dependabot and pre-commit.ci). +# +# For every bot PR, this workflow: +# 1. Adds the `skip news` label, which exempts the PR from the changelog +# check (dependabot PRs already get it via .github/dependabot.yml). +# The corresponding changelog entries are added in bulk at release +# time with the help of ./cicd_utils/find-unmentioned-prs.sh +# 2. Enables GitHub's native auto-merge (squash). The PR will then merge +# automatically as soon as the branch protection requirements are met +# (i.e., an approving review plus all required status checks passing). +# +# The only human action left is the review itself. +# +# Note: If auto-merge is enabled with the default GITHUB_TOKEN, the resulting +# merge commit will not trigger other workflows (e.g., the CI run on main or +# the TestPyPI publish). To get those post-merge runs, create a fine-grained +# PAT with contents:write and pull-requests:write scoped to this repository +# and store it as the AUTO_MERGE_PAT secret. This workflow falls back to +# GITHUB_TOKEN when the secret is not set. +# +# Security: this workflow runs on pull_request_target but never checks out +# or executes code from the PR branch. +# +name: Bot PRs + +on: + pull_request_target: + types: [ opened, reopened ] + +permissions: {} + +jobs: + automate: + name: Label and enable auto-merge + if: >- + github.event.pull_request.user.login == 'dependabot[bot]' || + github.event.pull_request.user.login == 'pre-commit-ci[bot]' + runs-on: ubuntu-latest + timeout-minutes: 2 + permissions: + contents: write + pull-requests: write + steps: + - name: Add 'skip news' label + run: gh pr edit "$PR_URL" --add-label 'skip news' + env: + PR_URL: ${{ github.event.pull_request.html_url }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Enable auto-merge (squash) + run: gh pr merge --auto --squash "$PR_URL" + env: + PR_URL: ${{ github.event.pull_request.html_url }} + GH_TOKEN: ${{ secrets.AUTO_MERGE_PAT || secrets.GITHUB_TOKEN }} diff --git a/cicd_utils/find-unmentioned-prs.sh b/cicd_utils/find-unmentioned-prs.sh index 4f54ab3d..1c4df0ee 100755 --- a/cicd_utils/find-unmentioned-prs.sh +++ b/cicd_utils/find-unmentioned-prs.sh @@ -68,6 +68,7 @@ else echo "📋 PRs not mentioned in changelog (${#unmentioned_prs[@]} total):" echo + suggested_entries=() for pr in "${unmentioned_prs[@]}"; do # Get PR title and URL for better readability pr_info=$(gh pr view "$pr" --json title,url --jq '{title: .title, url: .url}') @@ -77,5 +78,13 @@ else echo " #$pr: $pr_title" echo " $pr_url" echo + + suggested_entries+=("- ${pr_title} ({gh-pr}\`${pr}\`)") + done + + echo "📝 Suggested changelog entries (review and paste under 'Unreleased changes'):" + echo + for entry in "${suggested_entries[@]}"; do + echo "$entry" done fi diff --git a/docs/development/release_process.md b/docs/development/release_process.md index 2d2f4927..4472de0b 100644 --- a/docs/development/release_process.md +++ b/docs/development/release_process.md @@ -6,7 +6,7 @@ You need to have push-access to the project's repository to make releases. Therefore, the following release steps are intended to be used as a reference for maintainers or [collaborators](https://docs.github.com/en/account-and-profile/setting-up-and-managing-your-personal-account-on-github/managing-user-account-settings/permission-levels-for-a-personal-account-repository#collaborator-access-for-a-repository-owned-by-a-personal-account) with push-access to the repository. ::: -1. Review the **`## Unreleased changes`** section at the top of the {repo-file}`docs/reference/changelog.md` file and, if necessary, group and/or split entries into relevant subsections (e.g., _Features_, _Docs_, _Bugfixes_, _Security_, etc.). Take a look at previous release notes for guidance and try to keep the format consistent. You can also use the `./cicd_utils/find-unmentioned-prs.sh` helper script to find merged PRs that were not mentioned in the changelog yet. +1. Review the **`## Unreleased changes`** section at the top of the {repo-file}`docs/reference/changelog.md` file and, if necessary, group and/or split entries into relevant subsections (e.g., _Features_, _Docs_, _Bugfixes_, _Security_, etc.). Take a look at previous release notes for guidance and try to keep the format consistent. You can also use the `./cicd_utils/find-unmentioned-prs.sh` helper script to find merged PRs that were not mentioned in the changelog yet. Note that PRs opened by trusted bots (e.g., dependabot and pre-commit.ci) are automatically labeled with `skip news` and auto-merged once approved (see {repo-file}`.github/workflows/bot-prs.yml`), so their changelog entries are intentionally deferred to this step: the helper script prints ready-to-paste entries for them (these typically belong under a _CI/CD_ subsection). 2. [Review](https://github.com/tpvasconcelos/ridgeplot/compare) new usages of `.. versionadded::`, `.. versionchanged::`, and `.. deprecated::` directives that were added to the documentation since the last release. If necessary, update the version numbers in these directives to reflect the new release version. * You can determine the latest release version by running `git describe --tags --abbrev=0` on the `main` branch. Based on this, you can determine the next release version by incrementing the relevant _MAJOR_, _MINOR_, or _PATCH_ numbers. 3. **IMPORTANT:** Remember to switch to the `main` branch and pull the latest changes before proceeding. From 43fbf03bc9de0a083c1770a277de5d20f4d6a41e Mon Sep 17 00:00:00 2001 From: Tomas Pereira de Vasconcelos Date: Fri, 24 Jul 2026 19:37:55 +0200 Subject: [PATCH 02/13] Add changelog entry for bot PR automation --- docs/reference/changelog.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/reference/changelog.md b/docs/reference/changelog.md index 69f0e29e..89477376 100644 --- a/docs/reference/changelog.md +++ b/docs/reference/changelog.md @@ -19,6 +19,7 @@ Unreleased changes - Bump actions/checkout from 6 to 7 ({gh-pr}`383`) - pre-commit autoupdate ({gh-pr}`379`) - Fix the test suite's compatibility with the latest pytest release ({gh-pr}`384`) +- Automate bot PR maintenance: weekly grouped dependabot updates, automatic `skip news` labeling, and auto-merge once approved ({gh-pr}`385`) --- From d52675d80d5f0a0d3289727af89bcc8cab7aa18c Mon Sep 17 00:00:00 2001 From: Tomas Pereira de Vasconcelos Date: Sat, 25 Jul 2026 07:40:04 +0200 Subject: [PATCH 03/13] Rework bot PR automation: commit changelog entries directly to bot PRs --- .github/dependabot.yml | 11 +- .github/workflows/bot-prs.yml | 157 ++++++++-- .../cicd/scripts/add_changelog_entry.py | 193 ++++++++++++ docs/development/release_process.md | 2 +- docs/reference/changelog.md | 2 +- .../test_scripts/test_add_changelog_entry.py | 295 ++++++++++++++++++ 6 files changed, 628 insertions(+), 32 deletions(-) create mode 100755 cicd_utils/cicd/scripts/add_changelog_entry.py create mode 100644 tests/cicd_utils/test_scripts/test_add_changelog_entry.py diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 5c673748..12f3204a 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -3,11 +3,8 @@ # Notes: # - Updates are grouped into a single weekly PR per ecosystem to reduce # review noise and avoid changelog merge conflicts between bot PRs. -# - The `skip news` label exempts bot PRs from the changelog check -# (see .github/workflows/check-release-notes.yml). Their changelog -# entries are added in bulk at release time with the help of the -# ./cicd_utils/find-unmentioned-prs.sh script. -# - Bot PRs are auto-merged once approved (see .github/workflows/bot-prs.yml). +# - Bot PRs get an automated changelog entry commit and are auto-merged +# once approved (see .github/workflows/bot-prs.yml). # # References: # - https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file @@ -18,7 +15,7 @@ updates: directory: "/" schedule: interval: "weekly" - labels: [ "github_actions", "skip news" ] + labels: [ "github_actions" ] groups: github-actions: patterns: [ "*" ] @@ -26,7 +23,7 @@ updates: directory: "/" schedule: interval: "weekly" - labels: [ "dependencies", "skip news" ] + labels: [ "dependencies" ] groups: pip: patterns: [ "*" ] diff --git a/.github/workflows/bot-prs.yml b/.github/workflows/bot-prs.yml index 7135a623..7e036178 100644 --- a/.github/workflows/bot-prs.yml +++ b/.github/workflows/bot-prs.yml @@ -1,54 +1,165 @@ # Automation for PRs opened by trusted bots (dependabot and pre-commit.ci). # # For every bot PR, this workflow: -# 1. Adds the `skip news` label, which exempts the PR from the changelog -# check (dependabot PRs already get it via .github/dependabot.yml). -# The corresponding changelog entries are added in bulk at release -# time with the help of ./cicd_utils/find-unmentioned-prs.sh +# 1. Commits a changelog entry for the PR (generated from the PR's title) +# directly to the PR branch, so that the "Check for entry in Changelog" +# requirement is satisfied and the entry can be reviewed as part of the +# PR itself. This step is idempotent and self-healing: if a bot +# force-pushes its branch (wiping our commit), the resulting +# `synchronize` event simply re-adds the entry. # 2. Enables GitHub's native auto-merge (squash). The PR will then merge # automatically as soon as the branch protection requirements are met # (i.e., an approving review plus all required status checks passing). # +# Additionally, on every push to main, the `heal` job checks whether any +# open bot PRs became conflicted (e.g., two bot PRs adding changelog +# entries at the same location: the first one to merge conflicts the +# other) and resolves them by merging main into the PR branch and +# regenerating the changelog entry. +# # The only human action left is the review itself. # -# Note: If auto-merge is enabled with the default GITHUB_TOKEN, the resulting -# merge commit will not trigger other workflows (e.g., the CI run on main or -# the TestPyPI publish). To get those post-merge runs, create a fine-grained -# PAT with contents:write and pull-requests:write scoped to this repository -# and store it as the AUTO_MERGE_PAT secret. This workflow falls back to -# GITHUB_TOKEN when the secret is not set. +# Note: If the AUTO_MERGE_PAT secret is not set, this workflow falls back to +# the default GITHUB_TOKEN. This comes with a significant caveat: pushes and +# merges performed with GITHUB_TOKEN do not trigger other workflows, meaning +# that (a) CI checks will not run against the changelog commits pushed to +# bot PRs, and (b) post-merge workflows on main (CI run, TestPyPI publish) +# will not be triggered. For the full experience, create a fine-grained PAT +# with contents:write and pull-requests:write scoped to this repository and +# store it as the AUTO_MERGE_PAT secret. # -# Security: this workflow runs on pull_request_target but never checks out -# or executes code from the PR branch. +# Security: this workflow runs on pull_request_target with write +# permissions, but only ever acts on same-repository PRs opened by +# trusted bots, and never executes code from the PR branch. # name: Bot PRs on: pull_request_target: - types: [ opened, reopened ] + types: [ opened, reopened, synchronize ] + push: + branches: [ main ] permissions: {} +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: false + jobs: - automate: - name: Label and enable auto-merge + changelog: + name: Add changelog entry if: >- - github.event.pull_request.user.login == 'dependabot[bot]' || - github.event.pull_request.user.login == 'pre-commit-ci[bot]' + github.event_name == 'pull_request_target' && + github.event.pull_request.head.repo.full_name == github.repository && + (github.event.pull_request.user.login == 'dependabot[bot]' || + github.event.pull_request.user.login == 'pre-commit-ci[bot]') runs-on: ubuntu-latest - timeout-minutes: 2 + timeout-minutes: 3 permissions: contents: write - pull-requests: write steps: - - name: Add 'skip news' label - run: gh pr edit "$PR_URL" --add-label 'skip news' + - uses: actions/checkout@v7 + with: + ref: ${{ github.event.pull_request.head.ref }} + token: ${{ secrets.AUTO_MERGE_PAT || secrets.GITHUB_TOKEN }} + + - name: Add changelog entry and push env: - PR_URL: ${{ github.event.pull_request.html_url }} - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_TITLE: ${{ github.event.pull_request.title }} + run: | + python3 cicd_utils/cicd/scripts/add_changelog_entry.py "$PR_NUMBER" "$PR_TITLE" + if git diff --quiet -- docs/reference/changelog.md; then + echo "Changelog entry already present; nothing to do." + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add docs/reference/changelog.md + git commit -m "Add changelog entry for #${PR_NUMBER}" + git push + automerge: + name: Enable auto-merge + if: >- + github.event_name == 'pull_request_target' && + github.event.action != 'synchronize' && + github.event.pull_request.head.repo.full_name == github.repository && + (github.event.pull_request.user.login == 'dependabot[bot]' || + github.event.pull_request.user.login == 'pre-commit-ci[bot]') + runs-on: ubuntu-latest + timeout-minutes: 2 + permissions: + contents: write + pull-requests: write + steps: - name: Enable auto-merge (squash) run: gh pr merge --auto --squash "$PR_URL" env: PR_URL: ${{ github.event.pull_request.html_url }} GH_TOKEN: ${{ secrets.AUTO_MERGE_PAT || secrets.GITHUB_TOKEN }} + + heal: + name: Heal conflicted bot PRs + if: github.event_name == 'push' + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + pull-requests: read + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + token: ${{ secrets.AUTO_MERGE_PAT || secrets.GITHUB_TOKEN }} + + - name: Merge main into conflicted bot PRs + env: + GH_TOKEN: ${{ secrets.AUTO_MERGE_PAT || secrets.GITHUB_TOKEN }} + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + prs=$( + gh pr list --author 'app/dependabot' --json number,headRefName,title --jq '.[] | [.number, .headRefName, .title] | @tsv' + gh pr list --author 'app/pre-commit-ci' --json number,headRefName,title --jq '.[] | [.number, .headRefName, .title] | @tsv' + ) + [ -n "$prs" ] || { echo "No open bot PRs."; exit 0; } + + while IFS=$'\t' read -r number head_ref title; do + [ -n "$number" ] || continue + + # Wait for GitHub to finish computing the mergeable state + mergeable=UNKNOWN + for _ in 1 2 3 4 5; do + mergeable=$(gh pr view "$number" --json mergeable --jq .mergeable) + [ "$mergeable" = "UNKNOWN" ] || break + sleep 10 + done + echo "PR #${number} (${head_ref}) is ${mergeable}" + [ "$mergeable" = "CONFLICTING" ] || continue + + git fetch origin "$head_ref" < /dev/null + git checkout -B "$head_ref" "origin/$head_ref" < /dev/null + + if git merge --no-edit origin/main < /dev/null; then + git push origin "HEAD:$head_ref" < /dev/null + continue + fi + + conflicts=$(git diff --name-only --diff-filter=U) + if [ "$conflicts" != "docs/reference/changelog.md" ]; then + git merge --abort < /dev/null + echo "::warning::PR #${number} has conflicts beyond the changelog; skipping." + continue + fi + + # Resolve the changelog conflict by taking main's version + # and re-generating this PR's changelog entry on top of it + git checkout --theirs docs/reference/changelog.md < /dev/null + python3 cicd_utils/cicd/scripts/add_changelog_entry.py "$number" "$title" + git add docs/reference/changelog.md + git commit --no-edit < /dev/null + git push origin "HEAD:$head_ref" < /dev/null + done <<< "$prs" diff --git a/cicd_utils/cicd/scripts/add_changelog_entry.py b/cicd_utils/cicd/scripts/add_changelog_entry.py new file mode 100755 index 00000000..b1c42492 --- /dev/null +++ b/cicd_utils/cicd/scripts/add_changelog_entry.py @@ -0,0 +1,193 @@ +#!/usr/bin/env python +"""Add a changelog entry for a given pull request. + +Inserts a ``- ({gh-pr}`<number>`)`` entry into the ``### CI/CD`` +subsection of the ``Unreleased changes`` section of the changelog, creating +the subsection (or the whole section) if it doesn't exist yet. + +This script is idempotent: if the changelog already references the given +PR number, the file is left unchanged. + +Used by the ``.github/workflows/bot-prs.yml`` workflow to automatically add +changelog entries to pull requests opened by trusted bots (e.g., dependabot +and pre-commit.ci). + +Usage: + python cicd_utils/cicd/scripts/add_changelog_entry.py <pr-number> <pr-title> +""" + +from __future__ import annotations + +import argparse +import re +from pathlib import Path + +PATH_ROOT_DIR = Path(__file__).parents[3] +PATH_TO_CHANGELOG = PATH_ROOT_DIR.joinpath("docs/reference/changelog.md") + +UNRELEASED_HEADING = "Unreleased changes" +CICD_HEADING = "### CI/CD" + +# Known bot prefixes that should be stripped from PR titles to +# match the changelog's entry conventions (e.g., the convention +# for pre-commit.ci PRs is simply "pre-commit autoupdate"). +STRIP_TITLE_PREFIXES = ("[pre-commit.ci] ",) + + +def format_entry(pr_number: int, pr_title: str) -> str: + """Format a changelog entry for the given PR number and title.""" + title = pr_title.strip() + for prefix in STRIP_TITLE_PREFIXES: + title = title.removeprefix(prefix) + return f"- {title} ({{gh-pr}}`{pr_number}`)" + + +def _is_setext_underline(lines: list[str], i: int) -> bool: + """Whether ``lines[i]`` is a setext heading underline (e.g., ``-----``). + + A dash-only line is a setext underline if it directly follows a + non-blank line. Otherwise, it is a thematic break (e.g., ``---``). + """ + if not re.fullmatch(r"-{2,}", lines[i].strip()): + return False + return i > 0 and bool(lines[i - 1].strip()) + + +def _is_thematic_break(lines: list[str], i: int) -> bool: + """Whether ``lines[i]`` is a thematic break (e.g., ``---``).""" + return bool(re.fullmatch(r"-{3,}", lines[i].strip())) and not _is_setext_underline(lines, i) + + +def _find_unreleased_section(lines: list[str]) -> tuple[int, int] | None: + """Find the (start, end) line indices of the 'Unreleased changes' section. + + ``start`` points at the section's heading text line and ``end`` at the + heading text line of the next section (or one past the last line). + Returns :data:`None` if the section doesn't exist. + """ + start = None + for i in range(len(lines) - 1): + if lines[i].strip() == UNRELEASED_HEADING and _is_setext_underline(lines, i + 1): + start = i + break + if start is None: + return None + for j in range(start + 2, len(lines)): + if _is_setext_underline(lines, j): + return start, j - 1 + return start, len(lines) + + +def _new_unreleased_section(entry: str) -> list[str]: + return [ + UNRELEASED_HEADING, + "-" * len(UNRELEASED_HEADING), + "", + CICD_HEADING, + "", + entry, + "", + "---", + "", + ] + + +def _insert_unreleased_section(lines: list[str], entry: str) -> list[str]: + """Insert a whole new 'Unreleased changes' section before the first section.""" + for i in range(len(lines)): + if _is_setext_underline(lines, i): + first_heading = i - 1 + return [ + *lines[:first_heading], + *_new_unreleased_section(entry), + *lines[first_heading:], + ] + # No sections yet (e.g., an empty changelog): append at the end, making + # sure that the new section's heading is preceded by a blank line + # (otherwise the preceding paragraph would absorb the setext heading) + if lines and lines[-1].strip(): + lines = [*lines, ""] + return [*lines, *_new_unreleased_section(entry)] + + +def _insert_cicd_subsection(lines: list[str], start: int, end: int, entry: str) -> list[str]: + """Insert a new '### CI/CD' subsection at the end of the 'Unreleased changes' section.""" + # Insert before the section's trailing thematic break (if any) + insert_at = end + for i in range(start + 2, end): + if _is_thematic_break(lines, i): + insert_at = i + break + # Walk back over any blank lines + while insert_at > start + 2 and not lines[insert_at - 1].strip(): + insert_at -= 1 + return [*lines[:insert_at], "", CICD_HEADING, "", entry, *lines[insert_at:]] + + +def _append_to_cicd_subsection(lines: list[str], cicd_at: int, end: int, entry: str) -> list[str]: + """Append an entry at the end of an existing '### CI/CD' subsection.""" + # The subsection ends at the next subsection heading, the section's + # trailing thematic break, or the end of the section (whichever + # comes first). + insert_at = end + for i in range(cicd_at + 1, end): + if lines[i].startswith("### ") or _is_thematic_break(lines, i): + insert_at = i + break + # Walk back over any blank lines + while insert_at > cicd_at + 1 and not lines[insert_at - 1].strip(): + insert_at -= 1 + return [*lines[:insert_at], entry, *lines[insert_at:]] + + +def add_changelog_entry(changelog: Path, pr_number: int, pr_title: str) -> bool: + """Add a changelog entry for the given PR. Returns whether the file was changed.""" + text = changelog.read_text() + if f"{{gh-pr}}`{pr_number}`" in text: + print(f"Changelog already references PR #{pr_number}; nothing to do.") + return False + + entry = format_entry(pr_number, pr_title) + lines = text.splitlines() + + section = _find_unreleased_section(lines) + if section is None: + lines = _insert_unreleased_section(lines, entry) + else: + start, end = section + cicd_at = next( + (i for i in range(start + 2, end) if lines[i].strip() == CICD_HEADING), + None, + ) + if cicd_at is None: + lines = _insert_cicd_subsection(lines, start, end, entry) + else: + lines = _append_to_cicd_subsection(lines, cicd_at, end, entry) + + while lines and not lines[-1].strip(): + lines.pop() + changelog.write_text("\n".join(lines) + "\n") + print(f"Added changelog entry: {entry}") + return True + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("pr_number", type=int, help="The pull request number.") + parser.add_argument("pr_title", type=str, help="The pull request title.") + parser.add_argument( + "--changelog", + type=Path, + default=PATH_TO_CHANGELOG, + help="Path to the changelog file.", + ) + args = parser.parse_args() + add_changelog_entry( + changelog=args.changelog, + pr_number=args.pr_number, + pr_title=args.pr_title, + ) + + +if __name__ == "__main__": + main() diff --git a/docs/development/release_process.md b/docs/development/release_process.md index 4472de0b..d7b81bb9 100644 --- a/docs/development/release_process.md +++ b/docs/development/release_process.md @@ -6,7 +6,7 @@ You need to have push-access to the project's repository to make releases. Therefore, the following release steps are intended to be used as a reference for maintainers or [collaborators](https://docs.github.com/en/account-and-profile/setting-up-and-managing-your-personal-account-on-github/managing-user-account-settings/permission-levels-for-a-personal-account-repository#collaborator-access-for-a-repository-owned-by-a-personal-account) with push-access to the repository. ::: -1. Review the **`## Unreleased changes`** section at the top of the {repo-file}`docs/reference/changelog.md` file and, if necessary, group and/or split entries into relevant subsections (e.g., _Features_, _Docs_, _Bugfixes_, _Security_, etc.). Take a look at previous release notes for guidance and try to keep the format consistent. You can also use the `./cicd_utils/find-unmentioned-prs.sh` helper script to find merged PRs that were not mentioned in the changelog yet. Note that PRs opened by trusted bots (e.g., dependabot and pre-commit.ci) are automatically labeled with `skip news` and auto-merged once approved (see {repo-file}`.github/workflows/bot-prs.yml`), so their changelog entries are intentionally deferred to this step: the helper script prints ready-to-paste entries for them (these typically belong under a _CI/CD_ subsection). +1. Review the **`## Unreleased changes`** section at the top of the {repo-file}`docs/reference/changelog.md` file and, if necessary, group and/or split entries into relevant subsections (e.g., _Features_, _Docs_, _Bugfixes_, _Security_, etc.). Take a look at previous release notes for guidance and try to keep the format consistent. You can also use the `./cicd_utils/find-unmentioned-prs.sh` helper script to find merged PRs that were not mentioned in the changelog yet (if any are found, it prints ready-to-paste changelog entries for them). Note that PRs opened by trusted bots (e.g., dependabot and pre-commit.ci) get an automated changelog entry commit and are auto-merged once approved (see {repo-file}`.github/workflows/bot-prs.yml`), so they should already be covered in the changelog. 2. [Review](https://github.com/tpvasconcelos/ridgeplot/compare) new usages of `.. versionadded::`, `.. versionchanged::`, and `.. deprecated::` directives that were added to the documentation since the last release. If necessary, update the version numbers in these directives to reflect the new release version. * You can determine the latest release version by running `git describe --tags --abbrev=0` on the `main` branch. Based on this, you can determine the next release version by incrementing the relevant _MAJOR_, _MINOR_, or _PATCH_ numbers. 3. **IMPORTANT:** Remember to switch to the `main` branch and pull the latest changes before proceeding. diff --git a/docs/reference/changelog.md b/docs/reference/changelog.md index 44a455c4..3c885a10 100644 --- a/docs/reference/changelog.md +++ b/docs/reference/changelog.md @@ -30,7 +30,7 @@ Unreleased changes - Bump actions/checkout from 6 to 7 ({gh-pr}`383`) - pre-commit autoupdate ({gh-pr}`379`) - Fix the test suite's compatibility with the latest pytest release ({gh-pr}`384`) -- Automate bot PR maintenance: weekly grouped dependabot updates, automatic `skip news` labeling, and auto-merge once approved ({gh-pr}`385`) +- Automate bot PR maintenance: weekly grouped dependabot updates, automated changelog entries, and auto-merge once approved ({gh-pr}`385`) - Use the official `astral-sh/setup-uv` action to install and cache `uv` in CI ({gh-pr}`386`) --- diff --git a/tests/cicd_utils/test_scripts/test_add_changelog_entry.py b/tests/cicd_utils/test_scripts/test_add_changelog_entry.py new file mode 100644 index 00000000..9d438bb9 --- /dev/null +++ b/tests/cicd_utils/test_scripts/test_add_changelog_entry.py @@ -0,0 +1,295 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +from cicd.scripts.add_changelog_entry import ( + PATH_TO_CHANGELOG, + add_changelog_entry, + format_entry, + main, +) + +if TYPE_CHECKING: + from pathlib import Path + + +def test_path_to_changelog_exists() -> None: + assert PATH_TO_CHANGELOG.exists() + assert PATH_TO_CHANGELOG.is_file() + + +@pytest.mark.parametrize( + ("pr_number", "pr_title", "expected"), + [ + ( + 383, + "Bump actions/checkout from 6 to 7", + "- Bump actions/checkout from 6 to 7 ({gh-pr}`383`)", + ), + (379, "[pre-commit.ci] pre-commit autoupdate", "- pre-commit autoupdate ({gh-pr}`379`)"), + (42, " Padded title ", "- Padded title ({gh-pr}`42`)"), + ], +) +def test_format_entry(pr_number: int, pr_title: str, expected: str) -> None: + assert format_entry(pr_number, pr_title) == expected + + +CHANGELOG_WITH_CICD_SUBSECTION = """\ +# Release Notes + +Intro paragraph... + +Unreleased changes +------------------ + +### CI/CD + +- Old entry ({gh-pr}`100`) + +--- + +0.1.0 +----- + +- Old release change ({gh-pr}`99`) +""" + +EXPECTED_WITH_CICD_SUBSECTION = """\ +# Release Notes + +Intro paragraph... + +Unreleased changes +------------------ + +### CI/CD + +- Old entry ({gh-pr}`100`) +- Bump foo from 1 to 2 ({gh-pr}`123`) + +--- + +0.1.0 +----- + +- Old release change ({gh-pr}`99`) +""" + +CHANGELOG_WITHOUT_CICD_SUBSECTION = """\ +# Release Notes + +Unreleased changes +------------------ + +### Bug fixes + +- Fix something ({gh-pr}`101`) + +--- + +0.1.0 +----- + +- Old release change ({gh-pr}`99`) +""" + +EXPECTED_WITHOUT_CICD_SUBSECTION = """\ +# Release Notes + +Unreleased changes +------------------ + +### Bug fixes + +- Fix something ({gh-pr}`101`) + +### CI/CD + +- Bump foo from 1 to 2 ({gh-pr}`123`) + +--- + +0.1.0 +----- + +- Old release change ({gh-pr}`99`) +""" + +CHANGELOG_WITHOUT_UNRELEASED_SECTION = """\ +# Release Notes + +Intro paragraph... + +0.1.0 +----- + +- Old release change ({gh-pr}`99`) +""" + +EXPECTED_WITHOUT_UNRELEASED_SECTION = """\ +# Release Notes + +Intro paragraph... + +Unreleased changes +------------------ + +### CI/CD + +- Bump foo from 1 to 2 ({gh-pr}`123`) + +--- + +0.1.0 +----- + +- Old release change ({gh-pr}`99`) +""" + +CHANGELOG_WITHOUT_ANY_SECTIONS = """\ +# Release Notes + +Intro paragraph... +""" + +EXPECTED_WITHOUT_ANY_SECTIONS = """\ +# Release Notes + +Intro paragraph... + +Unreleased changes +------------------ + +### CI/CD + +- Bump foo from 1 to 2 ({gh-pr}`123`) + +--- +""" + +CHANGELOG_WITH_TRAILING_SUBSECTION = """\ +# Release Notes + +Unreleased changes +------------------ + +### CI/CD + +- Old entry ({gh-pr}`100`) + +### Documentation + +- Documentation change ({gh-pr}`101`) +""" + +EXPECTED_WITH_TRAILING_SUBSECTION = """\ +# Release Notes + +Unreleased changes +------------------ + +### CI/CD + +- Old entry ({gh-pr}`100`) +- Bump foo from 1 to 2 ({gh-pr}`123`) + +### Documentation + +- Documentation change ({gh-pr}`101`) +""" + +CHANGELOG_WITH_LOOSE_ENTRIES = """\ +# Release Notes + +Unreleased changes +------------------ + +- Loose entry ({gh-pr}`100`) +""" + +EXPECTED_WITH_LOOSE_ENTRIES = """\ +# Release Notes + +Unreleased changes +------------------ + +- Loose entry ({gh-pr}`100`) + +### CI/CD + +- Bump foo from 1 to 2 ({gh-pr}`123`) +""" + + +@pytest.mark.parametrize( + ("changelog_content", "expected_content"), + [ + (CHANGELOG_WITH_CICD_SUBSECTION, EXPECTED_WITH_CICD_SUBSECTION), + (CHANGELOG_WITHOUT_CICD_SUBSECTION, EXPECTED_WITHOUT_CICD_SUBSECTION), + (CHANGELOG_WITHOUT_UNRELEASED_SECTION, EXPECTED_WITHOUT_UNRELEASED_SECTION), + (CHANGELOG_WITHOUT_ANY_SECTIONS, EXPECTED_WITHOUT_ANY_SECTIONS), + (CHANGELOG_WITH_TRAILING_SUBSECTION, EXPECTED_WITH_TRAILING_SUBSECTION), + (CHANGELOG_WITH_LOOSE_ENTRIES, EXPECTED_WITH_LOOSE_ENTRIES), + ], + ids=[ + "existing-cicd-subsection", + "missing-cicd-subsection", + "missing-unreleased-section", + "missing-any-sections", + "trailing-subsection", + "loose-entries", + ], +) +def test_add_changelog_entry(changelog_content: str, expected_content: str, tmp_path: Path) -> None: + changelog_path = tmp_path / "changelog.md" + changelog_path.write_text(changelog_content) + changed = add_changelog_entry( + changelog=changelog_path, pr_number=123, pr_title="Bump foo from 1 to 2" + ) + assert changed is True + assert changelog_path.read_text() == expected_content + + +def test_add_changelog_entry_to_real_changelog(tmp_path: Path) -> None: + changelog_path = tmp_path / "changelog.md" + changelog_path.write_text(PATH_TO_CHANGELOG.read_text()) + changed = add_changelog_entry( + changelog=changelog_path, pr_number=999999, pr_title="Bump foo from 1 to 2" + ) + assert changed is True + text = changelog_path.read_text() + entry = "- Bump foo from 1 to 2 ({gh-pr}`999999`)" + assert entry in text + # The new entry should land in the unreleased section (i.e., before + # the first released version's section) + first_release_at = text.index("\n0.") + assert text.index(entry) < first_release_at + + +def test_add_changelog_entry_is_idempotent(tmp_path: Path) -> None: + changelog_path = tmp_path / "changelog.md" + changelog_path.write_text(CHANGELOG_WITH_CICD_SUBSECTION) + changed = add_changelog_entry( + changelog=changelog_path, pr_number=100, pr_title="Some already mentioned PR" + ) + assert changed is False + assert changelog_path.read_text() == CHANGELOG_WITH_CICD_SUBSECTION + + +def test_main(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + changelog_path = tmp_path / "changelog.md" + changelog_path.write_text(CHANGELOG_WITH_CICD_SUBSECTION) + monkeypatch.setattr( + "sys.argv", + [ + "add_changelog_entry.py", + "123", + "Bump foo from 1 to 2", + "--changelog", + str(changelog_path), + ], + ) + main() + assert changelog_path.read_text() == EXPECTED_WITH_CICD_SUBSECTION From 916d9d5ec2a39e0dabf71be4b13e10bbe47a21c5 Mon Sep 17 00:00:00 2001 From: Tomas Pereira de Vasconcelos <tomasvasconcelos1@gmail.com> Date: Sat, 25 Jul 2026 13:50:11 +0200 Subject: [PATCH 04/13] Rewrite add_changelog_entry.py using markdown-it-py source maps --- .github/workflows/bot-prs.yml | 2 + .../cicd/scripts/add_changelog_entry.py | 161 ++++++++++-------- requirements/cicd_utils.txt | 1 + .../test_scripts/test_add_changelog_entry.py | 109 ++++++++++++ 4 files changed, 200 insertions(+), 73 deletions(-) diff --git a/.github/workflows/bot-prs.yml b/.github/workflows/bot-prs.yml index 7e036178..cf792589 100644 --- a/.github/workflows/bot-prs.yml +++ b/.github/workflows/bot-prs.yml @@ -69,6 +69,7 @@ jobs: PR_NUMBER: ${{ github.event.pull_request.number }} PR_TITLE: ${{ github.event.pull_request.title }} run: | + python3 -m pip install --quiet markdown-it-py python3 cicd_utils/cicd/scripts/add_changelog_entry.py "$PR_NUMBER" "$PR_TITLE" if git diff --quiet -- docs/reference/changelog.md; then echo "Changelog entry already present; nothing to do." @@ -120,6 +121,7 @@ jobs: run: | git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + python3 -m pip install --quiet markdown-it-py prs=$( gh pr list --author 'app/dependabot' --json number,headRefName,title --jq '.[] | [.number, .headRefName, .title] | @tsv' diff --git a/cicd_utils/cicd/scripts/add_changelog_entry.py b/cicd_utils/cicd/scripts/add_changelog_entry.py index b1c42492..9e9676b9 100755 --- a/cicd_utils/cicd/scripts/add_changelog_entry.py +++ b/cicd_utils/cicd/scripts/add_changelog_entry.py @@ -5,6 +5,12 @@ subsection of the ``Unreleased changes`` section of the changelog, creating the subsection (or the whole section) if it doesn't exist yet. +The changelog's structure is discovered with ``markdown-it-py`` (using the +tokens' source line maps), while the actual edit is a surgical line splice. +This keeps the rest of the file byte-for-byte untouched (as opposed to, +e.g., re-rendering the whole document with ``mdformat``, which would +restyle it). + This script is idempotent: if the changelog already references the given PR number, the file is left unchanged. @@ -19,14 +25,19 @@ from __future__ import annotations import argparse -import re from pathlib import Path +from typing import TYPE_CHECKING + +from markdown_it import MarkdownIt + +if TYPE_CHECKING: + from markdown_it.token import Token PATH_ROOT_DIR = Path(__file__).parents[3] PATH_TO_CHANGELOG = PATH_ROOT_DIR.joinpath("docs/reference/changelog.md") UNRELEASED_HEADING = "Unreleased changes" -CICD_HEADING = "### CI/CD" +CICD_HEADING = "CI/CD" # Known bot prefixes that should be stripped from PR titles to # match the changelog's entry conventions (e.g., the convention @@ -42,40 +53,39 @@ def format_entry(pr_number: int, pr_title: str) -> str: return f"- {title} ({{gh-pr}}`{pr_number}`)" -def _is_setext_underline(lines: list[str], i: int) -> bool: - """Whether ``lines[i]`` is a setext heading underline (e.g., ``-----``). +def _token_lines(token: Token) -> tuple[int, int]: + """Return a block token's source line range as an ``(start, end)`` tuple.""" + if token.map is None: + raise AssertionError("Block-level tokens always carry a source line map") + return token.map[0], token.map[1] - A dash-only line is a setext underline if it directly follows a - non-blank line. Otherwise, it is a thematic break (e.g., ``---``). - """ - if not re.fullmatch(r"-{2,}", lines[i].strip()): - return False - return i > 0 and bool(lines[i - 1].strip()) - -def _is_thematic_break(lines: list[str], i: int) -> bool: - """Whether ``lines[i]`` is a thematic break (e.g., ``---``).""" - return bool(re.fullmatch(r"-{3,}", lines[i].strip())) and not _is_setext_underline(lines, i) +def _is_heading(tokens: list[Token], i: int, tag: str, text: str | None = None) -> bool: + """Whether ``tokens[i]`` opens a heading with the given tag (and text).""" + token = tokens[i] + if token.type != "heading_open" or token.tag != tag: + return False + return text is None or tokens[i + 1].content == text -def _find_unreleased_section(lines: list[str]) -> tuple[int, int] | None: - """Find the (start, end) line indices of the 'Unreleased changes' section. +def _find_unreleased_section(tokens: list[Token]) -> tuple[int, int] | None: + """Find the (start, end) token index range of the 'Unreleased changes' section. - ``start`` points at the section's heading text line and ``end`` at the - heading text line of the next section (or one past the last line). - Returns :data:`None` if the section doesn't exist. + ``start`` points at the first token after the section's heading and + ``end`` at the next section's ``heading_open`` token (or one past the + last token). Returns :data:`None` if the section doesn't exist. """ start = None - for i in range(len(lines) - 1): - if lines[i].strip() == UNRELEASED_HEADING and _is_setext_underline(lines, i + 1): - start = i - break + for i in range(len(tokens)): + if not _is_heading(tokens, i, tag="h2"): + continue + if start is not None: + return start, i + if _is_heading(tokens, i, tag="h2", text=UNRELEASED_HEADING): + start = i + 3 # skip the heading_open, inline, and heading_close tokens if start is None: return None - for j in range(start + 2, len(lines)): - if _is_setext_underline(lines, j): - return start, j - 1 - return start, len(lines) + return start, len(tokens) def _new_unreleased_section(entry: str) -> list[str]: @@ -83,7 +93,7 @@ def _new_unreleased_section(entry: str) -> list[str]: UNRELEASED_HEADING, "-" * len(UNRELEASED_HEADING), "", - CICD_HEADING, + f"### {CICD_HEADING}", "", entry, "", @@ -92,16 +102,12 @@ def _new_unreleased_section(entry: str) -> list[str]: ] -def _insert_unreleased_section(lines: list[str], entry: str) -> list[str]: +def _insert_unreleased_section(lines: list[str], tokens: list[Token], entry: str) -> list[str]: """Insert a whole new 'Unreleased changes' section before the first section.""" - for i in range(len(lines)): - if _is_setext_underline(lines, i): - first_heading = i - 1 - return [ - *lines[:first_heading], - *_new_unreleased_section(entry), - *lines[first_heading:], - ] + for i in range(len(tokens)): + if _is_heading(tokens, i, tag="h2"): + at = _token_lines(tokens[i])[0] + return [*lines[:at], *_new_unreleased_section(entry), *lines[at:]] # No sections yet (e.g., an empty changelog): append at the end, making # sure that the new section's heading is preceded by a blank line # (otherwise the preceding paragraph would absorb the setext heading) @@ -110,34 +116,37 @@ def _insert_unreleased_section(lines: list[str], entry: str) -> list[str]: return [*lines, *_new_unreleased_section(entry)] -def _insert_cicd_subsection(lines: list[str], start: int, end: int, entry: str) -> list[str]: - """Insert a new '### CI/CD' subsection at the end of the 'Unreleased changes' section.""" +def _find_cicd_insertion(tokens: list[Token], start: int, end: int) -> int | None: + """Find the source line at which to insert an entry into the '### CI/CD' subsection. + + Returns the line right after the subsection's last bullet list (or right + after its heading, if it contains no list yet), or :data:`None` if the + subsection doesn't exist. + """ + insert_at = None + for i in range(start, end): + token = tokens[i] + if token.type == "heading_open": + if insert_at is not None: + break # reached the next subsection + if _is_heading(tokens, i, tag="h3", text=CICD_HEADING): + insert_at = _token_lines(token)[1] + elif insert_at is not None and token.type == "hr": + break # reached the section's trailing thematic break + elif insert_at is not None and token.type == "bullet_list_open" and token.level == 0: + insert_at = _token_lines(token)[1] + return insert_at + + +def _find_subsection_insertion(tokens: list[Token], start: int, end: int, n_lines: int) -> int: + """Find the source line at which to insert a new subsection at the end of the section.""" # Insert before the section's trailing thematic break (if any) - insert_at = end - for i in range(start + 2, end): - if _is_thematic_break(lines, i): - insert_at = i - break - # Walk back over any blank lines - while insert_at > start + 2 and not lines[insert_at - 1].strip(): - insert_at -= 1 - return [*lines[:insert_at], "", CICD_HEADING, "", entry, *lines[insert_at:]] - - -def _append_to_cicd_subsection(lines: list[str], cicd_at: int, end: int, entry: str) -> list[str]: - """Append an entry at the end of an existing '### CI/CD' subsection.""" - # The subsection ends at the next subsection heading, the section's - # trailing thematic break, or the end of the section (whichever - # comes first). - insert_at = end - for i in range(cicd_at + 1, end): - if lines[i].startswith("### ") or _is_thematic_break(lines, i): - insert_at = i - break - # Walk back over any blank lines - while insert_at > cicd_at + 1 and not lines[insert_at - 1].strip(): - insert_at -= 1 - return [*lines[:insert_at], entry, *lines[insert_at:]] + for i in range(start, end): + if tokens[i].type == "hr": + return _token_lines(tokens[i])[0] + if end < len(tokens): + return _token_lines(tokens[end])[0] + return n_lines def add_changelog_entry(changelog: Path, pr_number: int, pr_title: str) -> bool: @@ -149,20 +158,26 @@ def add_changelog_entry(changelog: Path, pr_number: int, pr_title: str) -> bool: entry = format_entry(pr_number, pr_title) lines = text.splitlines() + tokens = MarkdownIt().parse(text) - section = _find_unreleased_section(lines) + section = _find_unreleased_section(tokens) if section is None: - lines = _insert_unreleased_section(lines, entry) + lines = _insert_unreleased_section(lines, tokens, entry) else: start, end = section - cicd_at = next( - (i for i in range(start + 2, end) if lines[i].strip() == CICD_HEADING), - None, - ) - if cicd_at is None: - lines = _insert_cicd_subsection(lines, start, end, entry) + insert_at = _find_cicd_insertion(tokens, start, end) + if insert_at is None: + insert_at = _find_subsection_insertion(tokens, start, end, len(lines)) + new_lines = ["", f"### {CICD_HEADING}", "", entry] else: - lines = _append_to_cicd_subsection(lines, cicd_at, end, entry) + new_lines = [entry] + # Walk back over any blank lines + while insert_at > 0 and not lines[insert_at - 1].strip(): + insert_at -= 1 + if new_lines == [entry] and lines[insert_at - 1].lstrip().startswith("#"): + # Keep a blank line between a heading and the first entry + new_lines = ["", entry] + lines[insert_at:insert_at] = new_lines while lines and not lines[-1].strip(): lines.pop() diff --git a/requirements/cicd_utils.txt b/requirements/cicd_utils.txt index 6cd107d9..ea70970d 100644 --- a/requirements/cicd_utils.txt +++ b/requirements/cicd_utils.txt @@ -4,6 +4,7 @@ minify-html kaleido<0.4 # ./cicd_utils/cicd/scripts/extract_latest_release_notes.py +# ./cicd_utils/cicd/scripts/add_changelog_entry.py (markdown-it-py only) markdown-it-py mdit-py-plugins mdformat diff --git a/tests/cicd_utils/test_scripts/test_add_changelog_entry.py b/tests/cicd_utils/test_scripts/test_add_changelog_entry.py index 9d438bb9..75b07f2d 100644 --- a/tests/cicd_utils/test_scripts/test_add_changelog_entry.py +++ b/tests/cicd_utils/test_scripts/test_add_changelog_entry.py @@ -222,6 +222,109 @@ def test_format_entry(pr_number: int, pr_title: str, expected: str) -> None: - Bump foo from 1 to 2 ({gh-pr}`123`) """ +CHANGELOG_WITH_ATX_HEADINGS = """\ +# Release Notes + +## Unreleased changes + +### CI/CD + +- Old entry ({gh-pr}`100`) + +--- + +## 0.1.0 + +- Old release change ({gh-pr}`99`) +""" + +EXPECTED_WITH_ATX_HEADINGS = """\ +# Release Notes + +## Unreleased changes + +### CI/CD + +- Old entry ({gh-pr}`100`) +- Bump foo from 1 to 2 ({gh-pr}`123`) + +--- + +## 0.1.0 + +- Old release change ({gh-pr}`99`) +""" + +CHANGELOG_WITH_EMPTY_CICD_SUBSECTION = """\ +# Release Notes + +Unreleased changes +------------------ + +### CI/CD + +--- + +0.1.0 +----- + +- Old release change ({gh-pr}`99`) +""" + +EXPECTED_WITH_EMPTY_CICD_SUBSECTION = """\ +# Release Notes + +Unreleased changes +------------------ + +### CI/CD + +- Bump foo from 1 to 2 ({gh-pr}`123`) + +--- + +0.1.0 +----- + +- Old release change ({gh-pr}`99`) +""" + +CHANGELOG_WITHOUT_THEMATIC_BREAK = """\ +# Release Notes + +Unreleased changes +------------------ + +### Bug fixes + +- Fix something ({gh-pr}`101`) + +0.1.0 +----- + +- Old release change ({gh-pr}`99`) +""" + +EXPECTED_WITHOUT_THEMATIC_BREAK = """\ +# Release Notes + +Unreleased changes +------------------ + +### Bug fixes + +- Fix something ({gh-pr}`101`) + +### CI/CD + +- Bump foo from 1 to 2 ({gh-pr}`123`) + +0.1.0 +----- + +- Old release change ({gh-pr}`99`) +""" + @pytest.mark.parametrize( ("changelog_content", "expected_content"), @@ -232,6 +335,9 @@ def test_format_entry(pr_number: int, pr_title: str, expected: str) -> None: (CHANGELOG_WITHOUT_ANY_SECTIONS, EXPECTED_WITHOUT_ANY_SECTIONS), (CHANGELOG_WITH_TRAILING_SUBSECTION, EXPECTED_WITH_TRAILING_SUBSECTION), (CHANGELOG_WITH_LOOSE_ENTRIES, EXPECTED_WITH_LOOSE_ENTRIES), + (CHANGELOG_WITH_ATX_HEADINGS, EXPECTED_WITH_ATX_HEADINGS), + (CHANGELOG_WITH_EMPTY_CICD_SUBSECTION, EXPECTED_WITH_EMPTY_CICD_SUBSECTION), + (CHANGELOG_WITHOUT_THEMATIC_BREAK, EXPECTED_WITHOUT_THEMATIC_BREAK), ], ids=[ "existing-cicd-subsection", @@ -240,6 +346,9 @@ def test_format_entry(pr_number: int, pr_title: str, expected: str) -> None: "missing-any-sections", "trailing-subsection", "loose-entries", + "atx-headings", + "empty-cicd-subsection", + "missing-thematic-break", ], ) def test_add_changelog_entry(changelog_content: str, expected_content: str, tmp_path: Path) -> None: From 8cf5e358f188ca0d0546cd01544a26c1531d5893 Mon Sep 17 00:00:00 2001 From: Tomas Pereira de Vasconcelos <tomasvasconcelos1@gmail.com> Date: Tue, 28 Jul 2026 01:11:01 +0200 Subject: [PATCH 05/13] Never execute code from PR branches in the bot PR automation Both the changelog and heal jobs now run add_changelog_entry.py from a checkout of main (trusted code), only ever treating the PR branches' content as data. Previously the script was executed from the PR branch itself, contradicting the workflow's security claim. --- .github/workflows/bot-prs.yml | 38 +++++++++++++++++++++++++++++------ 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/.github/workflows/bot-prs.yml b/.github/workflows/bot-prs.yml index cf792589..47078bfb 100644 --- a/.github/workflows/bot-prs.yml +++ b/.github/workflows/bot-prs.yml @@ -30,7 +30,10 @@ # # Security: this workflow runs on pull_request_target with write # permissions, but only ever acts on same-repository PRs opened by -# trusted bots, and never executes code from the PR branch. +# trusted bots, and never executes code from the PR branches: both jobs +# run the changelog helper script from a checkout of the main branch +# (which is also where this workflow definition itself is taken from), +# only ever treating the PR branches' content as data. # name: Bot PRs @@ -59,27 +62,44 @@ jobs: permissions: contents: write steps: + # Note: this checks out the *base* branch (main), not the PR branch, + # so that the changelog helper script executed below always comes + # from trusted code and never from the PR branch. - uses: actions/checkout@v7 with: - ref: ${{ github.event.pull_request.head.ref }} token: ${{ secrets.AUTO_MERGE_PAT || secrets.GITHUB_TOKEN }} - name: Add changelog entry and push env: PR_NUMBER: ${{ github.event.pull_request.number }} PR_TITLE: ${{ github.event.pull_request.title }} + HEAD_REF: ${{ github.event.pull_request.head.ref }} run: | python3 -m pip install --quiet markdown-it-py - python3 cicd_utils/cicd/scripts/add_changelog_entry.py "$PR_NUMBER" "$PR_TITLE" - if git diff --quiet -- docs/reference/changelog.md; then + + # Apply the (trusted, from main) helper script to the PR + # branch's version of the changelog + git fetch origin "$HEAD_REF" + git show FETCH_HEAD:docs/reference/changelog.md > "$RUNNER_TEMP/changelog.md" + cp "$RUNNER_TEMP/changelog.md" "$RUNNER_TEMP/changelog.orig.md" + python3 cicd_utils/cicd/scripts/add_changelog_entry.py "$PR_NUMBER" "$PR_TITLE" \ + --changelog "$RUNNER_TEMP/changelog.md" + if cmp -s "$RUNNER_TEMP/changelog.md" "$RUNNER_TEMP/changelog.orig.md"; then echo "Changelog entry already present; nothing to do." exit 0 fi + + # Commit the updated changelog on top of the PR branch. This + # materializes the bot's tree in the working directory, but no + # code from it is ever executed (do not add steps below that + # run anything from the working tree!). git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git checkout -B "$HEAD_REF" FETCH_HEAD + cp "$RUNNER_TEMP/changelog.md" docs/reference/changelog.md git add docs/reference/changelog.md git commit -m "Add changelog entry for #${PR_NUMBER}" - git push + git push origin "HEAD:$HEAD_REF" automerge: name: Enable auto-merge @@ -123,6 +143,11 @@ jobs: git config user.email "41898282+github-actions[bot]@users.noreply.github.com" python3 -m pip install --quiet markdown-it-py + # Stash a trusted copy of the helper script (we're on main here) + # so that we never execute code from the PR branches that get + # checked out in the loop below + cp cicd_utils/cicd/scripts/add_changelog_entry.py "$RUNNER_TEMP/add_changelog_entry.py" + prs=$( gh pr list --author 'app/dependabot' --json number,headRefName,title --jq '.[] | [.number, .headRefName, .title] | @tsv' gh pr list --author 'app/pre-commit-ci' --json number,headRefName,title --jq '.[] | [.number, .headRefName, .title] | @tsv' @@ -160,7 +185,8 @@ jobs: # Resolve the changelog conflict by taking main's version # and re-generating this PR's changelog entry on top of it git checkout --theirs docs/reference/changelog.md < /dev/null - python3 cicd_utils/cicd/scripts/add_changelog_entry.py "$number" "$title" + python3 "$RUNNER_TEMP/add_changelog_entry.py" "$number" "$title" \ + --changelog docs/reference/changelog.md git add docs/reference/changelog.md git commit --no-edit < /dev/null git push origin "HEAD:$head_ref" < /dev/null From 082203e546f322992686f11725d9df10fcd0b7d7 Mon Sep 17 00:00:00 2001 From: Tomas Pereira de Vasconcelos <tomasvasconcelos1@gmail.com> Date: Tue, 28 Jul 2026 11:54:39 +0200 Subject: [PATCH 06/13] Require the AUTO_MERGE_PAT secret instead of falling back to GITHUB_TOKEN The fallback did not just degrade the experience, it deadlocked the automation: pushes and merges performed with GITHUB_TOKEN do not trigger other workflows, so CI would never run on the changelog commits pushed to bot PRs, the required status checks would stay pending forever, and auto-merge would never fire. Every job now fails fast with an actionable error when the secret is missing. --- .github/workflows/bot-prs.yml | 72 +++++++++++++++++++++++++++++------ 1 file changed, 60 insertions(+), 12 deletions(-) diff --git a/.github/workflows/bot-prs.yml b/.github/workflows/bot-prs.yml index 47078bfb..1e9f756a 100644 --- a/.github/workflows/bot-prs.yml +++ b/.github/workflows/bot-prs.yml @@ -19,14 +19,23 @@ # # The only human action left is the review itself. # -# Note: If the AUTO_MERGE_PAT secret is not set, this workflow falls back to -# the default GITHUB_TOKEN. This comes with a significant caveat: pushes and -# merges performed with GITHUB_TOKEN do not trigger other workflows, meaning -# that (a) CI checks will not run against the changelog commits pushed to -# bot PRs, and (b) post-merge workflows on main (CI run, TestPyPI publish) -# will not be triggered. For the full experience, create a fine-grained PAT -# with contents:write and pull-requests:write scoped to this repository and -# store it as the AUTO_MERGE_PAT secret. +# Setup requirements: +# - An AUTO_MERGE_PAT repository secret: a fine-grained personal access +# token scoped to this repository only, with read/write permissions +# for "Contents" and "Pull requests". The default GITHUB_TOKEN is +# deliberately *not* used as a fallback: events caused by pushes and +# merges performed with GITHUB_TOKEN do not trigger other workflows +# (GitHub's recursive-workflow protection), which would deadlock this +# automation - CI would never run on the changelog commits pushed to +# bot PRs, so the required status checks would stay pending forever +# and auto-merge would never fire. Merges to main performed with +# GITHUB_TOKEN would similarly skip the post-merge workflows on main +# (CI run, TestPyPI publish). Every job therefore fails fast with a +# clear error if the secret is not configured. +# - "Allow auto-merge" enabled in the repository settings +# (Settings > General > Pull Requests). +# - Branch protection on main requiring an approving review and the +# relevant status checks (auto-merge only waits for *required* checks). # # Security: this workflow runs on pull_request_target with write # permissions, but only ever acts on same-repository PRs opened by @@ -62,12 +71,25 @@ jobs: permissions: contents: write steps: + - name: Ensure AUTO_MERGE_PAT is configured + env: + AUTO_MERGE_PAT: ${{ secrets.AUTO_MERGE_PAT }} + run: | + if [ -z "$AUTO_MERGE_PAT" ]; then + echo "::error::The AUTO_MERGE_PAT secret is not configured." \ + "Falling back to GITHUB_TOKEN would deadlock this automation" \ + "(its pushes/merges do not trigger other workflows, so" \ + "required status checks would never run). See the setup" \ + "requirements at the top of .github/workflows/bot-prs.yml." + exit 1 + fi + # Note: this checks out the *base* branch (main), not the PR branch, # so that the changelog helper script executed below always comes # from trusted code and never from the PR branch. - uses: actions/checkout@v7 with: - token: ${{ secrets.AUTO_MERGE_PAT || secrets.GITHUB_TOKEN }} + token: ${{ secrets.AUTO_MERGE_PAT }} - name: Add changelog entry and push env: @@ -115,11 +137,24 @@ jobs: contents: write pull-requests: write steps: + - name: Ensure AUTO_MERGE_PAT is configured + env: + AUTO_MERGE_PAT: ${{ secrets.AUTO_MERGE_PAT }} + run: | + if [ -z "$AUTO_MERGE_PAT" ]; then + echo "::error::The AUTO_MERGE_PAT secret is not configured." \ + "Falling back to GITHUB_TOKEN would deadlock this automation" \ + "(its pushes/merges do not trigger other workflows, so" \ + "required status checks would never run). See the setup" \ + "requirements at the top of .github/workflows/bot-prs.yml." + exit 1 + fi + - name: Enable auto-merge (squash) run: gh pr merge --auto --squash "$PR_URL" env: PR_URL: ${{ github.event.pull_request.html_url }} - GH_TOKEN: ${{ secrets.AUTO_MERGE_PAT || secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ secrets.AUTO_MERGE_PAT }} heal: name: Heal conflicted bot PRs @@ -130,14 +165,27 @@ jobs: contents: write pull-requests: read steps: + - name: Ensure AUTO_MERGE_PAT is configured + env: + AUTO_MERGE_PAT: ${{ secrets.AUTO_MERGE_PAT }} + run: | + if [ -z "$AUTO_MERGE_PAT" ]; then + echo "::error::The AUTO_MERGE_PAT secret is not configured." \ + "Falling back to GITHUB_TOKEN would deadlock this automation" \ + "(its pushes/merges do not trigger other workflows, so" \ + "required status checks would never run). See the setup" \ + "requirements at the top of .github/workflows/bot-prs.yml." + exit 1 + fi + - uses: actions/checkout@v7 with: fetch-depth: 0 - token: ${{ secrets.AUTO_MERGE_PAT || secrets.GITHUB_TOKEN }} + token: ${{ secrets.AUTO_MERGE_PAT }} - name: Merge main into conflicted bot PRs env: - GH_TOKEN: ${{ secrets.AUTO_MERGE_PAT || secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ secrets.AUTO_MERGE_PAT }} run: | git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" From 5f05a6f52979ff067b25d51cb596402f57343c93 Mon Sep 17 00:00:00 2001 From: Tomas Pereira de Vasconcelos <tomasvasconcelos1@gmail.com> Date: Tue, 28 Jul 2026 12:13:19 +0200 Subject: [PATCH 07/13] Add bot changelog entries to a sorted '### Dependencies' subsection Routing bot PRs into their own 'Dependencies' subsection keeps the 'CI/CD' subsection free for meaningful pipeline changes (and matches the subsection name already used by past releases). Entries are kept sorted alphabetically: the whole list is re-sorted on every insertion, which repairs manual ordering violations and, as a bonus, makes two open bot PRs less likely to collide on the same changelog line. Existing bot entries in the unreleased section were migrated to the new format; published release notes are left untouched. --- .../cicd/scripts/add_changelog_entry.py | 100 +++++++--- docs/reference/changelog.md | 23 ++- .../test_scripts/test_add_changelog_entry.py | 179 ++++++++++++++---- 3 files changed, 231 insertions(+), 71 deletions(-) diff --git a/cicd_utils/cicd/scripts/add_changelog_entry.py b/cicd_utils/cicd/scripts/add_changelog_entry.py index 9e9676b9..ed257171 100755 --- a/cicd_utils/cicd/scripts/add_changelog_entry.py +++ b/cicd_utils/cicd/scripts/add_changelog_entry.py @@ -1,9 +1,12 @@ #!/usr/bin/env python """Add a changelog entry for a given pull request. -Inserts a ``- <title> ({gh-pr}`<number>`)`` entry into the ``### CI/CD`` +Inserts a ``- <title> ({gh-pr}`<number>`)`` entry into the ``### Dependencies`` subsection of the ``Unreleased changes`` section of the changelog, creating -the subsection (or the whole section) if it doesn't exist yet. +the subsection (or the whole section) if it doesn't exist yet. The +subsection's entries are kept sorted alphabetically: the whole list is +re-sorted on every insertion, which also repairs any pre-existing ordering +violations (e.g., from manual edits). The changelog's structure is discovered with ``markdown-it-py`` (using the tokens' source line maps), while the actual edit is a surgical line splice. @@ -37,7 +40,7 @@ PATH_TO_CHANGELOG = PATH_ROOT_DIR.joinpath("docs/reference/changelog.md") UNRELEASED_HEADING = "Unreleased changes" -CICD_HEADING = "CI/CD" +DEPS_HEADING = "Dependencies" # Known bot prefixes that should be stripped from PR titles to # match the changelog's entry conventions (e.g., the convention @@ -93,7 +96,7 @@ def _new_unreleased_section(entry: str) -> list[str]: UNRELEASED_HEADING, "-" * len(UNRELEASED_HEADING), "", - f"### {CICD_HEADING}", + f"### {DEPS_HEADING}", "", entry, "", @@ -116,26 +119,68 @@ def _insert_unreleased_section(lines: list[str], tokens: list[Token], entry: str return [*lines, *_new_unreleased_section(entry)] -def _find_cicd_insertion(tokens: list[Token], start: int, end: int) -> int | None: - """Find the source line at which to insert an entry into the '### CI/CD' subsection. +def _find_deps_subsection(tokens: list[Token], start: int, end: int) -> tuple[int, int] | None: + """Find the (start, end) token index range of the '### Dependencies' subsection. - Returns the line right after the subsection's last bullet list (or right - after its heading, if it contains no list yet), or :data:`None` if the + ``start`` points at the subsection's ``heading_open`` token and ``end`` + at the next subsection's ``heading_open`` token, the section's trailing + thematic break, or the end of the section. Returns :data:`None` if the subsection doesn't exist. """ - insert_at = None + sub_start = None for i in range(start, end): token = tokens[i] if token.type == "heading_open": - if insert_at is not None: - break # reached the next subsection - if _is_heading(tokens, i, tag="h3", text=CICD_HEADING): - insert_at = _token_lines(token)[1] - elif insert_at is not None and token.type == "hr": - break # reached the section's trailing thematic break - elif insert_at is not None and token.type == "bullet_list_open" and token.level == 0: - insert_at = _token_lines(token)[1] - return insert_at + if sub_start is not None: + return sub_start, i + if _is_heading(tokens, i, tag="h3", text=DEPS_HEADING): + sub_start = i + elif sub_start is not None and token.type == "hr": + return sub_start, i + if sub_start is None: + return None + return sub_start, end + + +def _insert_entry_into_subsection( + lines: list[str], tokens: list[Token], sub_start: int, sub_end: int, entry: str +) -> list[str]: + """Insert the entry into the subsection's bullet list, keeping it sorted. + + All of the subsection's list items are re-sorted alphabetically as a + whole, which also repairs any pre-existing ordering violations. Each + item's source lines are moved as one block, so that multi-line entries + (continuation lines, nested lists, etc.) are preserved intact. + """ + blocks: list[list[str]] = [] + span_start = span_end = None + for i in range(sub_start, sub_end): + token = tokens[i] + if token.type != "list_item_open" or token.level != 1: + continue # only consider items of top-level bullet lists + item_start, item_end = _token_lines(token) + block = lines[item_start:item_end] + # An item's line map may extend over trailing blank lines + # (e.g., in loose lists); strip them so that the re-assembled + # list is a tight, contiguous block of entries + while block and not block[-1].strip(): + block.pop() + blocks.append(block) + if span_start is None: + span_start = item_start + span_end = item_end + if span_start is None or span_end is None: + # No bullet list yet: insert right after the subsection's heading, + # keeping a blank line between the heading and the first entry + insert_at = _token_lines(tokens[sub_start])[1] + return [*lines[:insert_at], "", entry, *lines[insert_at:]] + # Walk back over any trailing blank lines covered by the last item's map + while span_end > span_start and not lines[span_end - 1].strip(): + span_end -= 1 + blocks.append([entry]) + blocks.sort(key=lambda block: block[0].casefold()) + sorted_lines = [line for block in blocks for line in block] + return [*lines[:span_start], *sorted_lines, *lines[span_end:]] def _find_subsection_insertion(tokens: list[Token], start: int, end: int, n_lines: int) -> int: @@ -165,19 +210,16 @@ def add_changelog_entry(changelog: Path, pr_number: int, pr_title: str) -> bool: lines = _insert_unreleased_section(lines, tokens, entry) else: start, end = section - insert_at = _find_cicd_insertion(tokens, start, end) - if insert_at is None: + subsection = _find_deps_subsection(tokens, start, end) + if subsection is None: insert_at = _find_subsection_insertion(tokens, start, end, len(lines)) - new_lines = ["", f"### {CICD_HEADING}", "", entry] + # Walk back over any blank lines + while insert_at > 0 and not lines[insert_at - 1].strip(): + insert_at -= 1 + lines[insert_at:insert_at] = ["", f"### {DEPS_HEADING}", "", entry] else: - new_lines = [entry] - # Walk back over any blank lines - while insert_at > 0 and not lines[insert_at - 1].strip(): - insert_at -= 1 - if new_lines == [entry] and lines[insert_at - 1].lstrip().startswith("#"): - # Keep a blank line between a heading and the first entry - new_lines = ["", entry] - lines[insert_at:insert_at] = new_lines + sub_start, sub_end = subsection + lines = _insert_entry_into_subsection(lines, tokens, sub_start, sub_end, entry) while lines and not lines[-1].strip(): lines.pop() diff --git a/docs/reference/changelog.md b/docs/reference/changelog.md index 11538a9a..ac64e32b 100644 --- a/docs/reference/changelog.md +++ b/docs/reference/changelog.md @@ -22,22 +22,25 @@ Unreleased changes - Review the coverage configuration in light of `covdefaults`, adopting its `assert_never` exclusion and `skip_covered` report setting, and raising all package coverage gates to 100% ({gh-pr}`390`) - Adopt pytest's strict mode, following the recommendations from pytest's "Good Integration Practices" guide ({gh-pr}`387`) - Make the e2e path sanity check independent of the checkout directory's name, so tests can run from git worktrees ({gh-pr}`391`) +- Fix the test suite's compatibility with the latest pytest release ({gh-pr}`384`) +- Automate bot PR maintenance: weekly grouped dependabot updates, automated changelog entries, and auto-merge once approved ({gh-pr}`385`) +- Use the official `astral-sh/setup-uv` action to install and cache `uv` in CI ({gh-pr}`386`) +- Let `uv` manage the Python interpreter and virtual environment in CI ({gh-pr}`393`) +- Remove the stale `requirements/*.txt` glob from the CI cache key, left over from the migration to PEP 735 dependency groups ({gh-pr}`394`) + +### Dependencies + +- Bump actions/checkout from 6 to 7 ({gh-pr}`383`) - Bump actions/download-artifact from 7 to 8 ({gh-pr}`368`) +- Bump actions/github-script from 8 to 9 ({gh-pr}`373`) - Bump actions/upload-artifact from 6 to 7 ({gh-pr}`369`) -- Bump sigstore/gh-action-sigstore-python from 3.2.0 to 3.3.0 ({gh-pr}`370`) - Bump codecov/codecov-action from 5 to 6 ({gh-pr}`371`) -- Bump actions/github-script from 8 to 9 ({gh-pr}`373`) -- pre-commit autoupdate ({gh-pr}`374`) -- Bump softprops/action-gh-release from 2 to 3 ({gh-pr}`380`) - Bump codecov/codecov-action from 6 to 7 ({gh-pr}`381`) +- Bump sigstore/gh-action-sigstore-python from 3.2.0 to 3.3.0 ({gh-pr}`370`) - Bump sigstore/gh-action-sigstore-python from 3.3.0 to 3.4.0 ({gh-pr}`382`) -- Bump actions/checkout from 6 to 7 ({gh-pr}`383`) +- Bump softprops/action-gh-release from 2 to 3 ({gh-pr}`380`) +- pre-commit autoupdate ({gh-pr}`374`) - pre-commit autoupdate ({gh-pr}`379`) -- Fix the test suite's compatibility with the latest pytest release ({gh-pr}`384`) -- Automate bot PR maintenance: weekly grouped dependabot updates, automated changelog entries, and auto-merge once approved ({gh-pr}`385`) -- Use the official `astral-sh/setup-uv` action to install and cache `uv` in CI ({gh-pr}`386`) -- Let `uv` manage the Python interpreter and virtual environment in CI ({gh-pr}`393`) -- Remove the stale `requirements/*.txt` glob from the CI cache key, left over from the migration to PEP 735 dependency groups ({gh-pr}`394`) --- diff --git a/tests/cicd_utils/test_scripts/test_add_changelog_entry.py b/tests/cicd_utils/test_scripts/test_add_changelog_entry.py index 75b07f2d..a64344ed 100644 --- a/tests/cicd_utils/test_scripts/test_add_changelog_entry.py +++ b/tests/cicd_utils/test_scripts/test_add_changelog_entry.py @@ -36,7 +36,7 @@ def test_format_entry(pr_number: int, pr_title: str, expected: str) -> None: assert format_entry(pr_number, pr_title) == expected -CHANGELOG_WITH_CICD_SUBSECTION = """\ +CHANGELOG_WITH_DEPS_SUBSECTION = """\ # Release Notes Intro paragraph... @@ -44,7 +44,7 @@ def test_format_entry(pr_number: int, pr_title: str, expected: str) -> None: Unreleased changes ------------------ -### CI/CD +### Dependencies - Old entry ({gh-pr}`100`) @@ -56,7 +56,7 @@ def test_format_entry(pr_number: int, pr_title: str, expected: str) -> None: - Old release change ({gh-pr}`99`) """ -EXPECTED_WITH_CICD_SUBSECTION = """\ +EXPECTED_WITH_DEPS_SUBSECTION = """\ # Release Notes Intro paragraph... @@ -64,10 +64,10 @@ def test_format_entry(pr_number: int, pr_title: str, expected: str) -> None: Unreleased changes ------------------ -### CI/CD +### Dependencies -- Old entry ({gh-pr}`100`) - Bump foo from 1 to 2 ({gh-pr}`123`) +- Old entry ({gh-pr}`100`) --- @@ -77,7 +77,7 @@ def test_format_entry(pr_number: int, pr_title: str, expected: str) -> None: - Old release change ({gh-pr}`99`) """ -CHANGELOG_WITHOUT_CICD_SUBSECTION = """\ +CHANGELOG_WITHOUT_DEPS_SUBSECTION = """\ # Release Notes Unreleased changes @@ -95,7 +95,7 @@ def test_format_entry(pr_number: int, pr_title: str, expected: str) -> None: - Old release change ({gh-pr}`99`) """ -EXPECTED_WITHOUT_CICD_SUBSECTION = """\ +EXPECTED_WITHOUT_DEPS_SUBSECTION = """\ # Release Notes Unreleased changes @@ -105,7 +105,7 @@ def test_format_entry(pr_number: int, pr_title: str, expected: str) -> None: - Fix something ({gh-pr}`101`) -### CI/CD +### Dependencies - Bump foo from 1 to 2 ({gh-pr}`123`) @@ -136,7 +136,7 @@ def test_format_entry(pr_number: int, pr_title: str, expected: str) -> None: Unreleased changes ------------------ -### CI/CD +### Dependencies - Bump foo from 1 to 2 ({gh-pr}`123`) @@ -162,7 +162,7 @@ def test_format_entry(pr_number: int, pr_title: str, expected: str) -> None: Unreleased changes ------------------ -### CI/CD +### Dependencies - Bump foo from 1 to 2 ({gh-pr}`123`) @@ -175,7 +175,7 @@ def test_format_entry(pr_number: int, pr_title: str, expected: str) -> None: Unreleased changes ------------------ -### CI/CD +### Dependencies - Old entry ({gh-pr}`100`) @@ -190,10 +190,10 @@ def test_format_entry(pr_number: int, pr_title: str, expected: str) -> None: Unreleased changes ------------------ -### CI/CD +### Dependencies -- Old entry ({gh-pr}`100`) - Bump foo from 1 to 2 ({gh-pr}`123`) +- Old entry ({gh-pr}`100`) ### Documentation @@ -217,7 +217,7 @@ def test_format_entry(pr_number: int, pr_title: str, expected: str) -> None: - Loose entry ({gh-pr}`100`) -### CI/CD +### Dependencies - Bump foo from 1 to 2 ({gh-pr}`123`) """ @@ -227,7 +227,7 @@ def test_format_entry(pr_number: int, pr_title: str, expected: str) -> None: ## Unreleased changes -### CI/CD +### Dependencies - Old entry ({gh-pr}`100`) @@ -243,10 +243,10 @@ def test_format_entry(pr_number: int, pr_title: str, expected: str) -> None: ## Unreleased changes -### CI/CD +### Dependencies -- Old entry ({gh-pr}`100`) - Bump foo from 1 to 2 ({gh-pr}`123`) +- Old entry ({gh-pr}`100`) --- @@ -255,13 +255,13 @@ def test_format_entry(pr_number: int, pr_title: str, expected: str) -> None: - Old release change ({gh-pr}`99`) """ -CHANGELOG_WITH_EMPTY_CICD_SUBSECTION = """\ +CHANGELOG_WITH_EMPTY_DEPS_SUBSECTION = """\ # Release Notes Unreleased changes ------------------ -### CI/CD +### Dependencies --- @@ -271,13 +271,13 @@ def test_format_entry(pr_number: int, pr_title: str, expected: str) -> None: - Old release change ({gh-pr}`99`) """ -EXPECTED_WITH_EMPTY_CICD_SUBSECTION = """\ +EXPECTED_WITH_EMPTY_DEPS_SUBSECTION = """\ # Release Notes Unreleased changes ------------------ -### CI/CD +### Dependencies - Bump foo from 1 to 2 ({gh-pr}`123`) @@ -315,7 +315,7 @@ def test_format_entry(pr_number: int, pr_title: str, expected: str) -> None: - Fix something ({gh-pr}`101`) -### CI/CD +### Dependencies - Bump foo from 1 to 2 ({gh-pr}`123`) @@ -325,30 +325,128 @@ def test_format_entry(pr_number: int, pr_title: str, expected: str) -> None: - Old release change ({gh-pr}`99`) """ +CHANGELOG_WITH_UNSORTED_ENTRIES = """\ +# Release Notes + +Unreleased changes +------------------ + +### Dependencies + +- pre-commit autoupdate ({gh-pr}`102`) +- Bump zebra from 3 to 4 ({gh-pr}`101`) +- Bump apple from 1 to 2 ({gh-pr}`100`) + +--- +""" + +EXPECTED_WITH_UNSORTED_ENTRIES = """\ +# Release Notes + +Unreleased changes +------------------ + +### Dependencies + +- Bump apple from 1 to 2 ({gh-pr}`100`) +- Bump foo from 1 to 2 ({gh-pr}`123`) +- Bump zebra from 3 to 4 ({gh-pr}`101`) +- pre-commit autoupdate ({gh-pr}`102`) + +--- +""" + +CHANGELOG_WITH_MULTILINE_ITEM = """\ +# Release Notes + +Unreleased changes +------------------ + +### Dependencies + +- Bump zebra from 3 to 4 ({gh-pr}`101`) + (this bump includes breaking changes) +- Bump apple from 1 to 2 ({gh-pr}`100`) + +--- +""" + +EXPECTED_WITH_MULTILINE_ITEM = """\ +# Release Notes + +Unreleased changes +------------------ + +### Dependencies + +- Bump apple from 1 to 2 ({gh-pr}`100`) +- Bump foo from 1 to 2 ({gh-pr}`123`) +- Bump zebra from 3 to 4 ({gh-pr}`101`) + (this bump includes breaking changes) + +--- +""" + +CHANGELOG_WITH_LOOSE_LIST = """\ +# Release Notes + +Unreleased changes +------------------ + +### Dependencies + +- Bump zebra from 3 to 4 ({gh-pr}`101`) + +- Bump apple from 1 to 2 ({gh-pr}`100`) + +--- +""" + +EXPECTED_WITH_LOOSE_LIST = """\ +# Release Notes + +Unreleased changes +------------------ + +### Dependencies + +- Bump apple from 1 to 2 ({gh-pr}`100`) +- Bump foo from 1 to 2 ({gh-pr}`123`) +- Bump zebra from 3 to 4 ({gh-pr}`101`) + +--- +""" + @pytest.mark.parametrize( ("changelog_content", "expected_content"), [ - (CHANGELOG_WITH_CICD_SUBSECTION, EXPECTED_WITH_CICD_SUBSECTION), - (CHANGELOG_WITHOUT_CICD_SUBSECTION, EXPECTED_WITHOUT_CICD_SUBSECTION), + (CHANGELOG_WITH_DEPS_SUBSECTION, EXPECTED_WITH_DEPS_SUBSECTION), + (CHANGELOG_WITHOUT_DEPS_SUBSECTION, EXPECTED_WITHOUT_DEPS_SUBSECTION), (CHANGELOG_WITHOUT_UNRELEASED_SECTION, EXPECTED_WITHOUT_UNRELEASED_SECTION), (CHANGELOG_WITHOUT_ANY_SECTIONS, EXPECTED_WITHOUT_ANY_SECTIONS), (CHANGELOG_WITH_TRAILING_SUBSECTION, EXPECTED_WITH_TRAILING_SUBSECTION), (CHANGELOG_WITH_LOOSE_ENTRIES, EXPECTED_WITH_LOOSE_ENTRIES), (CHANGELOG_WITH_ATX_HEADINGS, EXPECTED_WITH_ATX_HEADINGS), - (CHANGELOG_WITH_EMPTY_CICD_SUBSECTION, EXPECTED_WITH_EMPTY_CICD_SUBSECTION), + (CHANGELOG_WITH_EMPTY_DEPS_SUBSECTION, EXPECTED_WITH_EMPTY_DEPS_SUBSECTION), (CHANGELOG_WITHOUT_THEMATIC_BREAK, EXPECTED_WITHOUT_THEMATIC_BREAK), + (CHANGELOG_WITH_UNSORTED_ENTRIES, EXPECTED_WITH_UNSORTED_ENTRIES), + (CHANGELOG_WITH_MULTILINE_ITEM, EXPECTED_WITH_MULTILINE_ITEM), + (CHANGELOG_WITH_LOOSE_LIST, EXPECTED_WITH_LOOSE_LIST), ], ids=[ - "existing-cicd-subsection", - "missing-cicd-subsection", + "existing-deps-subsection", + "missing-deps-subsection", "missing-unreleased-section", "missing-any-sections", "trailing-subsection", "loose-entries", "atx-headings", - "empty-cicd-subsection", + "empty-deps-subsection", "missing-thematic-break", + "unsorted-existing-entries", + "multi-line-item", + "loose-list-items", ], ) def test_add_changelog_entry(changelog_content: str, expected_content: str, tmp_path: Path) -> None: @@ -361,6 +459,23 @@ def test_add_changelog_entry(changelog_content: str, expected_content: str, tmp_ assert changelog_path.read_text() == expected_content +def test_add_changelog_entry_sorts_case_insensitively(tmp_path: Path) -> None: + # With a case-sensitive sort, "Update ..." (uppercase "U") would + # wrongly sort before "pre-commit ..." (lowercase "p") + changelog_path = tmp_path / "changelog.md" + changelog_path.write_text( + CHANGELOG_WITH_DEPS_SUBSECTION.replace("Old entry", "pre-commit autoupdate") + ) + changed = add_changelog_entry( + changelog=changelog_path, pr_number=123, pr_title="Update foo to v2" + ) + assert changed is True + text = changelog_path.read_text() + pre_commit_entry = "- pre-commit autoupdate ({gh-pr}`100`)" + update_entry = "- Update foo to v2 ({gh-pr}`123`)" + assert text.index(pre_commit_entry) < text.index(update_entry) + + def test_add_changelog_entry_to_real_changelog(tmp_path: Path) -> None: changelog_path = tmp_path / "changelog.md" changelog_path.write_text(PATH_TO_CHANGELOG.read_text()) @@ -379,17 +494,17 @@ def test_add_changelog_entry_to_real_changelog(tmp_path: Path) -> None: def test_add_changelog_entry_is_idempotent(tmp_path: Path) -> None: changelog_path = tmp_path / "changelog.md" - changelog_path.write_text(CHANGELOG_WITH_CICD_SUBSECTION) + changelog_path.write_text(CHANGELOG_WITH_DEPS_SUBSECTION) changed = add_changelog_entry( changelog=changelog_path, pr_number=100, pr_title="Some already mentioned PR" ) assert changed is False - assert changelog_path.read_text() == CHANGELOG_WITH_CICD_SUBSECTION + assert changelog_path.read_text() == CHANGELOG_WITH_DEPS_SUBSECTION def test_main(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: changelog_path = tmp_path / "changelog.md" - changelog_path.write_text(CHANGELOG_WITH_CICD_SUBSECTION) + changelog_path.write_text(CHANGELOG_WITH_DEPS_SUBSECTION) monkeypatch.setattr( "sys.argv", [ @@ -401,4 +516,4 @@ def test_main(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: ], ) main() - assert changelog_path.read_text() == EXPECTED_WITH_CICD_SUBSECTION + assert changelog_path.read_text() == EXPECTED_WITH_DEPS_SUBSECTION From bf5c21e41c9583bd199fb40b3637b2357843c754 Mon Sep 17 00:00:00 2001 From: Tomas Pereira de Vasconcelos <tomasvasconcelos1@gmail.com> Date: Thu, 30 Jul 2026 00:18:03 +0200 Subject: [PATCH 08/13] Rename the bot PR workflow to bot-pr-automation.yml "Bot PRs" only named the subject; "Bot PR automation" describes what the workflow actually does (changelog entries, auto-merge, and conflict healing for trusted bot PRs) and reads better in check contexts (e.g., "Bot PR automation / Add changelog entry"). --- .github/dependabot.yml | 2 +- .github/workflows/{bot-prs.yml => bot-pr-automation.yml} | 8 ++++---- cicd_utils/cicd/scripts/add_changelog_entry.py | 2 +- docs/development/release_process.md | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) rename .github/workflows/{bot-prs.yml => bot-pr-automation.yml} (99%) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 12f3204a..22b207af 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -4,7 +4,7 @@ # - Updates are grouped into a single weekly PR per ecosystem to reduce # review noise and avoid changelog merge conflicts between bot PRs. # - Bot PRs get an automated changelog entry commit and are auto-merged -# once approved (see .github/workflows/bot-prs.yml). +# once approved (see .github/workflows/bot-pr-automation.yml). # # References: # - https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file diff --git a/.github/workflows/bot-prs.yml b/.github/workflows/bot-pr-automation.yml similarity index 99% rename from .github/workflows/bot-prs.yml rename to .github/workflows/bot-pr-automation.yml index 1e9f756a..0ee32eaa 100644 --- a/.github/workflows/bot-prs.yml +++ b/.github/workflows/bot-pr-automation.yml @@ -44,7 +44,7 @@ # (which is also where this workflow definition itself is taken from), # only ever treating the PR branches' content as data. # -name: Bot PRs +name: Bot PR automation on: pull_request_target: @@ -80,7 +80,7 @@ jobs: "Falling back to GITHUB_TOKEN would deadlock this automation" \ "(its pushes/merges do not trigger other workflows, so" \ "required status checks would never run). See the setup" \ - "requirements at the top of .github/workflows/bot-prs.yml." + "requirements at the top of .github/workflows/bot-pr-automation.yml." exit 1 fi @@ -146,7 +146,7 @@ jobs: "Falling back to GITHUB_TOKEN would deadlock this automation" \ "(its pushes/merges do not trigger other workflows, so" \ "required status checks would never run). See the setup" \ - "requirements at the top of .github/workflows/bot-prs.yml." + "requirements at the top of .github/workflows/bot-pr-automation.yml." exit 1 fi @@ -174,7 +174,7 @@ jobs: "Falling back to GITHUB_TOKEN would deadlock this automation" \ "(its pushes/merges do not trigger other workflows, so" \ "required status checks would never run). See the setup" \ - "requirements at the top of .github/workflows/bot-prs.yml." + "requirements at the top of .github/workflows/bot-pr-automation.yml." exit 1 fi diff --git a/cicd_utils/cicd/scripts/add_changelog_entry.py b/cicd_utils/cicd/scripts/add_changelog_entry.py index ed257171..34e03d7d 100755 --- a/cicd_utils/cicd/scripts/add_changelog_entry.py +++ b/cicd_utils/cicd/scripts/add_changelog_entry.py @@ -17,7 +17,7 @@ This script is idempotent: if the changelog already references the given PR number, the file is left unchanged. -Used by the ``.github/workflows/bot-prs.yml`` workflow to automatically add +Used by the ``.github/workflows/bot-pr-automation.yml`` workflow to automatically add changelog entries to pull requests opened by trusted bots (e.g., dependabot and pre-commit.ci). diff --git a/docs/development/release_process.md b/docs/development/release_process.md index d7b81bb9..4a4c6e3c 100644 --- a/docs/development/release_process.md +++ b/docs/development/release_process.md @@ -6,7 +6,7 @@ You need to have push-access to the project's repository to make releases. Therefore, the following release steps are intended to be used as a reference for maintainers or [collaborators](https://docs.github.com/en/account-and-profile/setting-up-and-managing-your-personal-account-on-github/managing-user-account-settings/permission-levels-for-a-personal-account-repository#collaborator-access-for-a-repository-owned-by-a-personal-account) with push-access to the repository. ::: -1. Review the **`## Unreleased changes`** section at the top of the {repo-file}`docs/reference/changelog.md` file and, if necessary, group and/or split entries into relevant subsections (e.g., _Features_, _Docs_, _Bugfixes_, _Security_, etc.). Take a look at previous release notes for guidance and try to keep the format consistent. You can also use the `./cicd_utils/find-unmentioned-prs.sh` helper script to find merged PRs that were not mentioned in the changelog yet (if any are found, it prints ready-to-paste changelog entries for them). Note that PRs opened by trusted bots (e.g., dependabot and pre-commit.ci) get an automated changelog entry commit and are auto-merged once approved (see {repo-file}`.github/workflows/bot-prs.yml`), so they should already be covered in the changelog. +1. Review the **`## Unreleased changes`** section at the top of the {repo-file}`docs/reference/changelog.md` file and, if necessary, group and/or split entries into relevant subsections (e.g., _Features_, _Docs_, _Bugfixes_, _Security_, etc.). Take a look at previous release notes for guidance and try to keep the format consistent. You can also use the `./cicd_utils/find-unmentioned-prs.sh` helper script to find merged PRs that were not mentioned in the changelog yet (if any are found, it prints ready-to-paste changelog entries for them). Note that PRs opened by trusted bots (e.g., dependabot and pre-commit.ci) get an automated changelog entry commit and are auto-merged once approved (see {repo-file}`.github/workflows/bot-pr-automation.yml`), so they should already be covered in the changelog. 2. [Review](https://github.com/tpvasconcelos/ridgeplot/compare) new usages of `.. versionadded::`, `.. versionchanged::`, and `.. deprecated::` directives that were added to the documentation since the last release. If necessary, update the version numbers in these directives to reflect the new release version. * You can determine the latest release version by running `git describe --tags --abbrev=0` on the `main` branch. Based on this, you can determine the next release version by incrementing the relevant _MAJOR_, _MINOR_, or _PATCH_ numbers. 3. **IMPORTANT:** Remember to switch to the `main` branch and pull the latest changes before proceeding. From 436a5e21d2818816ef6c60cd59fdf614280e37ba Mon Sep 17 00:00:00 2001 From: Tomas Pereira de Vasconcelos <tomasvasconcelos1@gmail.com> Date: Thu, 30 Jul 2026 01:01:36 +0200 Subject: [PATCH 09/13] Heal bot PRs that fall behind main, not just conflicted ones With "require branches to be up to date before merging" enabled, auto-merge waits forever on bot PRs that merely fell behind main, since nothing else updates their branches. The heal job now detects staleness locally (is the tip of main an ancestor of the PR branch?) and merges main into any bot PR that lacks it, covering both the behind and the conflicted cases with a single code path. This also removes the flaky polling of GitHub's asynchronous mergeability computation. --- .github/workflows/bot-pr-automation.yml | 39 ++++++++++++++----------- 1 file changed, 22 insertions(+), 17 deletions(-) diff --git a/.github/workflows/bot-pr-automation.yml b/.github/workflows/bot-pr-automation.yml index 0ee32eaa..c210d569 100644 --- a/.github/workflows/bot-pr-automation.yml +++ b/.github/workflows/bot-pr-automation.yml @@ -11,11 +11,17 @@ # automatically as soon as the branch protection requirements are met # (i.e., an approving review plus all required status checks passing). # -# Additionally, on every push to main, the `heal` job checks whether any -# open bot PRs became conflicted (e.g., two bot PRs adding changelog -# entries at the same location: the first one to merge conflicts the -# other) and resolves them by merging main into the PR branch and -# regenerating the changelog entry. +# Additionally, on every push to main, the `heal` job brings all open bot +# PRs whose branches no longer contain the tip of main back into a +# mergeable state by merging main into them: +# - PRs that merely fell behind main are updated so that they keep +# satisfying the "require branches to be up to date before merging" +# branch protection setting (which would otherwise leave auto-merge +# waiting forever, since nothing else updates bot PR branches). +# - PRs that became conflicted (e.g., two bot PRs whose changelog +# entries collide once the first one merges) are additionally +# resolved by taking main's version of the changelog and +# regenerating the PR's own changelog entry on top of it. # # The only human action left is the review itself. # @@ -157,7 +163,7 @@ jobs: GH_TOKEN: ${{ secrets.AUTO_MERGE_PAT }} heal: - name: Heal conflicted bot PRs + name: Heal out-of-date bot PRs if: github.event_name == 'push' runs-on: ubuntu-latest timeout-minutes: 10 @@ -183,7 +189,7 @@ jobs: fetch-depth: 0 token: ${{ secrets.AUTO_MERGE_PAT }} - - name: Merge main into conflicted bot PRs + - name: Merge main into out-of-date or conflicted bot PRs env: GH_TOKEN: ${{ secrets.AUTO_MERGE_PAT }} run: | @@ -205,17 +211,16 @@ jobs: while IFS=$'\t' read -r number head_ref title; do [ -n "$number" ] || continue - # Wait for GitHub to finish computing the mergeable state - mergeable=UNKNOWN - for _ in 1 2 3 4 5; do - mergeable=$(gh pr view "$number" --json mergeable --jq .mergeable) - [ "$mergeable" = "UNKNOWN" ] || break - sleep 10 - done - echo "PR #${number} (${head_ref}) is ${mergeable}" - [ "$mergeable" = "CONFLICTING" ] || continue - + # Determine staleness locally (instead of polling GitHub's + # asynchronous mergeability computation): a PR branch needs + # healing whenever the tip of main is not part of its history git fetch origin "$head_ref" < /dev/null + if git merge-base --is-ancestor origin/main "origin/$head_ref" < /dev/null; then + echo "PR #${number} (${head_ref}) is up to date with main." + continue + fi + echo "PR #${number} (${head_ref}) is behind main; merging main into it..." + git checkout -B "$head_ref" "origin/$head_ref" < /dev/null if git merge --no-edit origin/main < /dev/null; then From 8610dcc19e3ce85158a53dbdf54a36c280c2164f Mon Sep 17 00:00:00 2001 From: Tomas Pereira de Vasconcelos <tomasvasconcelos1@gmail.com> Date: Thu, 30 Jul 2026 01:06:15 +0200 Subject: [PATCH 10/13] Add an aggregate 'All CI checks passed' job for branch protection Requiring this single, stable context instead of enumerating each matrix job means adding or dropping Python versions never requires touching the repository's required status checks (a stale required context would otherwise block every PR until manually removed). --- .github/workflows/ci.yml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3df39def..6e18e549 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -116,3 +116,22 @@ jobs: env_vars: OS,PYTHON fail_ci_if_error: true verbose: true + + # Aggregate job that succeeds only when every other job in this + # workflow succeeded. Branch protection requires this single, stable + # check instead of enumerating each (Python version) matrix job, so + # that adding or dropping Python versions never requires updating the + # repository's required status checks. + all-green: + name: All CI checks passed + # `always()` is load-bearing: without it, a failure in a needed job + # would leave this job "skipped", and GitHub treats skipped required + # checks as passing! + if: always() + needs: [ static-checks, software-tests ] + runs-on: ubuntu-latest + timeout-minutes: 1 + steps: + - name: Fail unless all needed jobs succeeded + if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') || contains(needs.*.result, 'skipped') + run: exit 1 From 32aae22e3c180400a08f811bc5f9432dcf9694dd Mon Sep 17 00:00:00 2001 From: Tomas Pereira de Vasconcelos <tomasvasconcelos1@gmail.com> Date: Thu, 30 Jul 2026 19:36:49 +0200 Subject: [PATCH 11/13] Replace the aggregate CI job with a self-maintaining all-checks gate Branch protection cannot express "require all checks to pass" natively, and any enumerated list of contexts (including the aggregate job's) can go stale as workflows evolve. The new "Require all checks" workflow polls the PR head commit's full roll-up of check runs and commit statuses (excluding itself) and only succeeds once everything that reported has passed. Branch protection then only ever needs to require this one context, and path-filtered checks are required exactly when they actually run. --- .github/workflows/bot-pr-automation.yml | 5 +- .github/workflows/ci.yml | 19 ---- .github/workflows/require-all-checks.yml | 137 +++++++++++++++++++++++ 3 files changed, 141 insertions(+), 20 deletions(-) create mode 100644 .github/workflows/require-all-checks.yml diff --git a/.github/workflows/bot-pr-automation.yml b/.github/workflows/bot-pr-automation.yml index c210d569..0b2c004c 100644 --- a/.github/workflows/bot-pr-automation.yml +++ b/.github/workflows/bot-pr-automation.yml @@ -41,7 +41,10 @@ # - "Allow auto-merge" enabled in the repository settings # (Settings > General > Pull Requests). # - Branch protection on main requiring an approving review and the -# relevant status checks (auto-merge only waits for *required* checks). +# "All checks passed" status check - the single required context, +# provided by .github/workflows/require-all-checks.yml, which gates +# on every check that reports on a PR (auto-merge only waits for +# *required* checks). # # Security: this workflow runs on pull_request_target with write # permissions, but only ever acts on same-repository PRs opened by diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6e18e549..3df39def 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -116,22 +116,3 @@ jobs: env_vars: OS,PYTHON fail_ci_if_error: true verbose: true - - # Aggregate job that succeeds only when every other job in this - # workflow succeeded. Branch protection requires this single, stable - # check instead of enumerating each (Python version) matrix job, so - # that adding or dropping Python versions never requires updating the - # repository's required status checks. - all-green: - name: All CI checks passed - # `always()` is load-bearing: without it, a failure in a needed job - # would leave this job "skipped", and GitHub treats skipped required - # checks as passing! - if: always() - needs: [ static-checks, software-tests ] - runs-on: ubuntu-latest - timeout-minutes: 1 - steps: - - name: Fail unless all needed jobs succeeded - if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') || contains(needs.*.result, 'skipped') - run: exit 1 diff --git a/.github/workflows/require-all-checks.yml b/.github/workflows/require-all-checks.yml new file mode 100644 index 00000000..695c5b5b --- /dev/null +++ b/.github/workflows/require-all-checks.yml @@ -0,0 +1,137 @@ +# A single, permanent "required status check" for branch protection. +# +# GitHub's branch protection cannot express "require all checks to pass": +# required status checks must be enumerated by name, and such a list goes +# stale as workflows, job names, and matrix dimensions (e.g., Python +# versions) evolve. Worse, a stale required context that never reports +# again would block every PR until branch protection is manually updated. +# +# Instead, branch protection requires only this workflow's "All checks +# passed" context. The gate job polls the GitHub API for every check run +# and commit status reported on the PR's head commit (excluding itself) +# and: +# - fails as soon as any of them fails, and +# - succeeds once all of them have completed successfully (also +# allowing the "neutral" and "skipped" conclusions), observed stable +# over a few consecutive polls. +# +# This makes the set of required checks self-maintaining: checks from +# path-filtered workflows are required exactly when they actually run on +# a PR, and new or renamed jobs are picked up automatically. +# +# Caveats (accepted trade-offs): +# - A check that registers unusually late (after the initial delay plus +# the stability window) could in theory be missed. In practice, all +# GitHub Actions check runs are registered as soon as their workflow +# runs are queued (i.e., within seconds of the triggering event), and +# the external services used here post a "pending" commit status +# early on. +# - If a failed check is re-run and turns green, this gate must be +# re-run too (one click in the PR's checks UI). Bot PRs are not +# affected in practice: any push to them re-triggers the gate. +# +name: Require all checks + +on: + pull_request: + # The labeled/unlabeled events matter because e.g. the "skip news" + # label re-runs the changelog check and can flip its result + types: [ opened, reopened, synchronize, labeled, unlabeled ] + +permissions: {} + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + all-checks-passed: + name: All checks passed + runs-on: ubuntu-latest + timeout-minutes: 40 + permissions: + checks: read + statuses: read + steps: + - name: Wait for all other checks on this commit + uses: actions/github-script@v9 + env: + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + with: + script: | + const OWN_NAME = 'All checks passed'; + const OK_CONCLUSIONS = new Set(['success', 'neutral', 'skipped']); + const INITIAL_DELAY_S = 60; + const POLL_INTERVAL_S = 30; + const REQUIRED_GREEN_POLLS = 3; + const DEADLINE_MS = Date.now() + 35 * 60 * 1000; + + const ref = process.env.HEAD_SHA; + const { owner, repo } = context.repo; + const sleep = (s) => new Promise((resolve) => setTimeout(resolve, s * 1000)); + + const snapshot = async () => { + // Check runs (GitHub Actions jobs and checks posted by apps, + // e.g. pre-commit.ci and CodeQL). The API's default + // filter=latest de-duplicates re-runs of the same check. + const runs = await github.paginate(github.rest.checks.listForRef, { + owner, repo, ref, per_page: 100, + }); + const checks = runs.filter((run) => run.name !== OWN_NAME); + // Legacy commit statuses (e.g. codecov, Codacy, CodeFactor, + // and Read the Docs). The endpoint returns the full history, + // newest first, so keep only the latest state per context. + const allStatuses = await github.paginate(github.rest.repos.listCommitStatusesForRef, { + owner, repo, ref, per_page: 100, + }); + const statuses = new Map(); + for (const status of allStatuses) { + if (!statuses.has(status.context)) statuses.set(status.context, status); + } + const failed = [ + ...checks + .filter((c) => c.status === 'completed' && !OK_CONCLUSIONS.has(c.conclusion)) + .map((c) => `${c.name} (${c.conclusion})`), + ...[...statuses.values()] + .filter((s) => s.state === 'failure' || s.state === 'error') + .map((s) => `${s.context} (${s.state})`), + ]; + const pending = [ + ...checks + .filter((c) => c.status !== 'completed') + .map((c) => `${c.name} (${c.status})`), + ...[...statuses.values()] + .filter((s) => s.state === 'pending') + .map((s) => `${s.context} (pending)`), + ]; + return { total: checks.length + statuses.size, failed, pending }; + }; + + // Give the other workflows and services triggered by this + // event a moment to register their check runs and statuses + await sleep(INITIAL_DELAY_S); + + let greenPolls = 0; + while (true) { + const { total, failed, pending } = await snapshot(); + if (failed.length > 0) { + core.setFailed(`Failed checks: ${failed.join(', ')}`); + return; + } + if (total > 0 && pending.length === 0) { + greenPolls += 1; + core.info(`All ${total} checks green (confirmation ${greenPolls}/${REQUIRED_GREEN_POLLS})`); + if (greenPolls >= REQUIRED_GREEN_POLLS) { + core.info('All checks on this commit have passed.'); + return; + } + } else { + greenPolls = 0; + core.info(`Waiting on: ${pending.join(', ') || '(no checks registered yet)'}`); + } + if (Date.now() > DEADLINE_MS) { + core.setFailed(`Timed out waiting for: ${pending.join(', ') || '(no checks registered)'}`); + return; + } + await sleep(POLL_INTERVAL_S); + } From a071c1e3347ca67b5a1ccd4010ec6c014228ecba Mon Sep 17 00:00:00 2001 From: Tomas Pereira de Vasconcelos <tomasvasconcelos1@gmail.com> Date: Thu, 30 Jul 2026 19:53:38 +0200 Subject: [PATCH 12/13] Adopt the ecosystem-standard CI gate job instead of an API-polling gate Research across GitHub's docs and well-regarded repositories (CPython, pydantic, uv, FastAPI, setuptools, attrs, pytest, aiohttp, and the Scientific Python guide) shows the established answer to "require all checks without enumerating matrix jobs" is an in-workflow aggregate job gated with `if: always()`, not polling the checks API. The polling approach was novel, raced against late-registering checks, and required manual re-runs after fixing a failed check. Branch protection will require the stable first-party contexts only; external services remain advisory, as is common practice (coverage is already enforced first-party via the tox/coverage gates inside CI). --- .github/workflows/bot-pr-automation.yml | 10 +- .github/workflows/ci.yml | 29 +++++ .github/workflows/require-all-checks.yml | 137 ----------------------- 3 files changed, 35 insertions(+), 141 deletions(-) delete mode 100644 .github/workflows/require-all-checks.yml diff --git a/.github/workflows/bot-pr-automation.yml b/.github/workflows/bot-pr-automation.yml index 0b2c004c..dc0b6f96 100644 --- a/.github/workflows/bot-pr-automation.yml +++ b/.github/workflows/bot-pr-automation.yml @@ -41,10 +41,12 @@ # - "Allow auto-merge" enabled in the repository settings # (Settings > General > Pull Requests). # - Branch protection on main requiring an approving review and the -# "All checks passed" status check - the single required context, -# provided by .github/workflows/require-all-checks.yml, which gates -# on every check that reports on a PR (auto-merge only waits for -# *required* checks). +# stable first-party status checks (auto-merge only waits for +# *required* checks): "All CI checks passed" (the ci.yml gate job +# covering the whole test matrix), "Check for entry in Changelog", +# "Build distributions", and "Analyze (Python)". External services +# (codecov, pre-commit.ci, Read the Docs, etc.) are advisory: they +# stay visible on the PR but merges don't depend on their uptime. # # Security: this workflow runs on pull_request_target with write # permissions, but only ever acts on same-repository PRs opened by diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3df39def..a2188500 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -116,3 +116,32 @@ jobs: env_vars: OS,PYTHON fail_ci_if_error: true verbose: true + + # Single, stable "gate" context for branch protection, following the + # pattern used by e.g. CPython, pydantic, uv, and FastAPI (see + # https://github.com/marketplace/actions/alls-green#why): branch + # protection requires only this one context instead of enumerating + # every matrix job, so adding or dropping Python versions never + # requires updating the repository settings. + all-green: + name: All CI checks passed + # `always()` is load-bearing: without it, a failure in a needed job + # would leave this job "skipped", and GitHub treats skipped required + # checks as passing! + if: always() + needs: [ static-checks, software-tests ] + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Fail unless all needed jobs succeeded + env: + NEEDS_JSON: ${{ toJSON(needs) }} + run: | + echo "$NEEDS_JSON" | jq . + failing=$(echo "$NEEDS_JSON" | jq -r \ + 'to_entries[] | select(.value.result != "success") | "\(.key): \(.value.result)"') + if [ -n "$failing" ]; then + echo "The following required jobs did not succeed:" + echo "$failing" + exit 1 + fi diff --git a/.github/workflows/require-all-checks.yml b/.github/workflows/require-all-checks.yml deleted file mode 100644 index 695c5b5b..00000000 --- a/.github/workflows/require-all-checks.yml +++ /dev/null @@ -1,137 +0,0 @@ -# A single, permanent "required status check" for branch protection. -# -# GitHub's branch protection cannot express "require all checks to pass": -# required status checks must be enumerated by name, and such a list goes -# stale as workflows, job names, and matrix dimensions (e.g., Python -# versions) evolve. Worse, a stale required context that never reports -# again would block every PR until branch protection is manually updated. -# -# Instead, branch protection requires only this workflow's "All checks -# passed" context. The gate job polls the GitHub API for every check run -# and commit status reported on the PR's head commit (excluding itself) -# and: -# - fails as soon as any of them fails, and -# - succeeds once all of them have completed successfully (also -# allowing the "neutral" and "skipped" conclusions), observed stable -# over a few consecutive polls. -# -# This makes the set of required checks self-maintaining: checks from -# path-filtered workflows are required exactly when they actually run on -# a PR, and new or renamed jobs are picked up automatically. -# -# Caveats (accepted trade-offs): -# - A check that registers unusually late (after the initial delay plus -# the stability window) could in theory be missed. In practice, all -# GitHub Actions check runs are registered as soon as their workflow -# runs are queued (i.e., within seconds of the triggering event), and -# the external services used here post a "pending" commit status -# early on. -# - If a failed check is re-run and turns green, this gate must be -# re-run too (one click in the PR's checks UI). Bot PRs are not -# affected in practice: any push to them re-triggers the gate. -# -name: Require all checks - -on: - pull_request: - # The labeled/unlabeled events matter because e.g. the "skip news" - # label re-runs the changelog check and can flip its result - types: [ opened, reopened, synchronize, labeled, unlabeled ] - -permissions: {} - -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number }} - cancel-in-progress: true - -jobs: - all-checks-passed: - name: All checks passed - runs-on: ubuntu-latest - timeout-minutes: 40 - permissions: - checks: read - statuses: read - steps: - - name: Wait for all other checks on this commit - uses: actions/github-script@v9 - env: - HEAD_SHA: ${{ github.event.pull_request.head.sha }} - with: - script: | - const OWN_NAME = 'All checks passed'; - const OK_CONCLUSIONS = new Set(['success', 'neutral', 'skipped']); - const INITIAL_DELAY_S = 60; - const POLL_INTERVAL_S = 30; - const REQUIRED_GREEN_POLLS = 3; - const DEADLINE_MS = Date.now() + 35 * 60 * 1000; - - const ref = process.env.HEAD_SHA; - const { owner, repo } = context.repo; - const sleep = (s) => new Promise((resolve) => setTimeout(resolve, s * 1000)); - - const snapshot = async () => { - // Check runs (GitHub Actions jobs and checks posted by apps, - // e.g. pre-commit.ci and CodeQL). The API's default - // filter=latest de-duplicates re-runs of the same check. - const runs = await github.paginate(github.rest.checks.listForRef, { - owner, repo, ref, per_page: 100, - }); - const checks = runs.filter((run) => run.name !== OWN_NAME); - // Legacy commit statuses (e.g. codecov, Codacy, CodeFactor, - // and Read the Docs). The endpoint returns the full history, - // newest first, so keep only the latest state per context. - const allStatuses = await github.paginate(github.rest.repos.listCommitStatusesForRef, { - owner, repo, ref, per_page: 100, - }); - const statuses = new Map(); - for (const status of allStatuses) { - if (!statuses.has(status.context)) statuses.set(status.context, status); - } - const failed = [ - ...checks - .filter((c) => c.status === 'completed' && !OK_CONCLUSIONS.has(c.conclusion)) - .map((c) => `${c.name} (${c.conclusion})`), - ...[...statuses.values()] - .filter((s) => s.state === 'failure' || s.state === 'error') - .map((s) => `${s.context} (${s.state})`), - ]; - const pending = [ - ...checks - .filter((c) => c.status !== 'completed') - .map((c) => `${c.name} (${c.status})`), - ...[...statuses.values()] - .filter((s) => s.state === 'pending') - .map((s) => `${s.context} (pending)`), - ]; - return { total: checks.length + statuses.size, failed, pending }; - }; - - // Give the other workflows and services triggered by this - // event a moment to register their check runs and statuses - await sleep(INITIAL_DELAY_S); - - let greenPolls = 0; - while (true) { - const { total, failed, pending } = await snapshot(); - if (failed.length > 0) { - core.setFailed(`Failed checks: ${failed.join(', ')}`); - return; - } - if (total > 0 && pending.length === 0) { - greenPolls += 1; - core.info(`All ${total} checks green (confirmation ${greenPolls}/${REQUIRED_GREEN_POLLS})`); - if (greenPolls >= REQUIRED_GREEN_POLLS) { - core.info('All checks on this commit have passed.'); - return; - } - } else { - greenPolls = 0; - core.info(`Waiting on: ${pending.join(', ') || '(no checks registered yet)'}`); - } - if (Date.now() > DEADLINE_MS) { - core.setFailed(`Timed out waiting for: ${pending.join(', ') || '(no checks registered)'}`); - return; - } - await sleep(POLL_INTERVAL_S); - } From 97baa134418f026aaac47b8c7a007ece2b904c8b Mon Sep 17 00:00:00 2001 From: Tomas Pereira de Vasconcelos <tomasvasconcelos1@gmail.com> Date: Thu, 30 Jul 2026 20:19:39 +0200 Subject: [PATCH 13/13] Keep the required status checks as config-as-code in the repository The set of required status checks previously lived only in GitHub's branch protection settings, invisible to anyone reading the repo. It is now declared in .github/required-status-checks.json and applied with the new cicd_utils/apply-branch-protection.sh helper (which diffs the live configuration against the file and asks for confirmation), so the configuration is versioned, reviewable, and documented in code. --- .github/required-status-checks.json | 17 ++++++ .github/workflows/bot-pr-automation.yml | 15 +++-- .github/workflows/ci.yml | 3 +- cicd_utils/apply-branch-protection.sh | 80 +++++++++++++++++++++++++ 4 files changed, 108 insertions(+), 7 deletions(-) create mode 100644 .github/required-status-checks.json create mode 100755 cicd_utils/apply-branch-protection.sh diff --git a/.github/required-status-checks.json b/.github/required-status-checks.json new file mode 100644 index 00000000..ef769af4 --- /dev/null +++ b/.github/required-status-checks.json @@ -0,0 +1,17 @@ +{ + "strict": true, + "checks": [ + { + "context": "All CI checks passed" + }, + { + "context": "Check for entry in Changelog" + }, + { + "context": "Build distributions" + }, + { + "context": "Analyze (Python)" + } + ] +} diff --git a/.github/workflows/bot-pr-automation.yml b/.github/workflows/bot-pr-automation.yml index dc0b6f96..ea08fc8f 100644 --- a/.github/workflows/bot-pr-automation.yml +++ b/.github/workflows/bot-pr-automation.yml @@ -41,12 +41,15 @@ # - "Allow auto-merge" enabled in the repository settings # (Settings > General > Pull Requests). # - Branch protection on main requiring an approving review and the -# stable first-party status checks (auto-merge only waits for -# *required* checks): "All CI checks passed" (the ci.yml gate job -# covering the whole test matrix), "Check for entry in Changelog", -# "Build distributions", and "Analyze (Python)". External services -# (codecov, pre-commit.ci, Read the Docs, etc.) are advisory: they -# stay visible on the PR but merges don't depend on their uptime. +# required status checks defined in +# .github/required-status-checks.json (the config-as-code source of +# truth; apply it with ./cicd_utils/apply-branch-protection.sh). +# These are stable, first-party contexts only - the ci.yml +# "All CI checks passed" gate job covers the whole test matrix, so +# Python version changes never require touching the list. External +# services (codecov, pre-commit.ci, Read the Docs, etc.) are +# advisory: they stay visible on PRs, but merges don't depend on +# their uptime. (Auto-merge only waits for *required* checks.) # # Security: this workflow runs on pull_request_target with write # permissions, but only ever acts on same-repository PRs opened by diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a2188500..67368556 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -122,7 +122,8 @@ jobs: # https://github.com/marketplace/actions/alls-green#why): branch # protection requires only this one context instead of enumerating # every matrix job, so adding or dropping Python versions never - # requires updating the repository settings. + # requires updating the repository settings. The full list of + # required contexts lives in .github/required-status-checks.json. all-green: name: All CI checks passed # `always()` is load-bearing: without it, a failure in a needed job diff --git a/cicd_utils/apply-branch-protection.sh b/cicd_utils/apply-branch-protection.sh new file mode 100755 index 00000000..97c6378a --- /dev/null +++ b/cicd_utils/apply-branch-protection.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash + +# Apply the repository's required-status-checks configuration to main +# +# The status checks that must pass before merging into main are kept as +# config-as-code in .github/required-status-checks.json (the single +# source of truth; GitHub's branch protection settings UI is just a +# mirror of it). This script pushes that configuration to GitHub. It +# shows the current and desired configurations and asks for +# confirmation before applying (pass --yes to skip the prompt). +# +# Notes on the configuration itself: +# * "checks" should only ever contain stable, first-party contexts. In +# particular, the "All CI checks passed" gate job in ci.yml covers +# the whole test matrix, so adding/dropping Python versions never +# requires updating this list. External services (codecov, +# pre-commit.ci, Read the Docs, etc.) are advisory by design: they +# stay visible on PRs, but merges don't depend on their uptime. +# * "strict" means PR branches must be up to date with main before +# merging (bot PRs are kept up to date automatically by the heal job +# in .github/workflows/bot-pr-automation.yml). +# +# Usage: ./cicd_utils/apply-branch-protection.sh [--yes] +# +# Requirements: +# * GitHub CLI (gh) must be installed and authenticated. +# * jq must be installed for JSON parsing. +# * The authenticated user must have admin access to the repository +# (branch protection can only be read and written by admins). +# * The script should be run from the root of the repository. + +set -euo pipefail + +CONFIG_FILE=".github/required-status-checks.json" +BRANCH="main" + +if [[ ! -f "$CONFIG_FILE" ]]; then + echo "Error: Config file not found at $CONFIG_FILE (run this script from the repo root)" + exit 1 +fi + +if ! command -v gh &> /dev/null; then + echo "Error: GitHub CLI (gh) is not installed" + exit 1 +fi + +repo=$(gh repo view --json nameWithOwner --jq .nameWithOwner) +endpoint="repos/${repo}/branches/${BRANCH}/protection/required_status_checks" +normalize='{strict: .strict, checks: [.checks[] | {context: .context}]}' + +echo "🔍 Current configuration (${repo}, branch: ${BRANCH}):" +if current=$(gh api "$endpoint" --jq "$normalize" 2>/dev/null); then + echo "$current" | jq . +else + current="" + echo "(no required status checks are currently configured)" +fi +echo + +echo "📄 Desired configuration (from ${CONFIG_FILE}):" +desired=$(jq "$normalize" "$CONFIG_FILE") +echo "$desired" | jq . +echo + +if [[ -n "$current" && "$(echo "$current" | jq -S .)" == "$(echo "$desired" | jq -S .)" ]]; then + echo "✅ Branch protection is already in sync with ${CONFIG_FILE}; nothing to do." + exit 0 +fi + +if [[ "${1:-}" != "--yes" ]]; then + read -r -p "Apply the desired configuration? [y/N] " answer || answer="" + if [[ "$answer" != "y" && "$answer" != "Y" ]]; then + echo "Aborted." + exit 1 + fi +fi + +gh api -X PATCH "$endpoint" --input "$CONFIG_FILE" > /dev/null +echo "🚀 Applied. New configuration:" +gh api "$endpoint" --jq "$normalize" | jq .