From c4cc0b893ae369de99438652020e687efa4bc868 Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Wed, 4 Mar 2026 09:28:31 +0100 Subject: [PATCH 1/9] rulesets: Don't automatically ask for copilot review Copilot review now uses scarse premium requests, and this will be taken from the person creating the PR without them having a saying on how their premium requests are used, so we change to manual review requests. Signed-off-by: Leandro Lucarella --- github-rulesets/python/Protect version branches.json | 1 - 1 file changed, 1 deletion(-) diff --git a/github-rulesets/python/Protect version branches.json b/github-rulesets/python/Protect version branches.json index a07ba535..aea148a5 100644 --- a/github-rulesets/python/Protect version branches.json +++ b/github-rulesets/python/Protect version branches.json @@ -28,7 +28,6 @@ "dismiss_stale_reviews_on_push": true, "required_approving_review_count": 1, "required_review_thread_resolution": false, - "automatic_copilot_code_review_enabled": true, "allowed_merge_methods": [ "merge", "squash", From e566e4f7f4e283961c490504a5c8869afba0eb42 Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Wed, 4 Mar 2026 09:30:44 +0100 Subject: [PATCH 2/9] rulesets: Update organization admin `actor_id` It seems like GitHub changed this ID. Signed-off-by: Leandro Lucarella --- github-rulesets/python/Protect version branches.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/github-rulesets/python/Protect version branches.json b/github-rulesets/python/Protect version branches.json index aea148a5..d021de0d 100644 --- a/github-rulesets/python/Protect version branches.json +++ b/github-rulesets/python/Protect version branches.json @@ -75,7 +75,7 @@ "bypass_mode": "always" }, { - "actor_id": 1, + "actor_id": null, "actor_type": "OrganizationAdmin", "bypass_mode": "always" } From 0a13f2925126281369d4f45167a874b1d8388f47 Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Wed, 4 Mar 2026 09:32:31 +0100 Subject: [PATCH 3/9] rulesets: Add new require_reviewers option Signed-off-by: Leandro Lucarella --- github-rulesets/python/Protect version branches.json | 1 + 1 file changed, 1 insertion(+) diff --git a/github-rulesets/python/Protect version branches.json b/github-rulesets/python/Protect version branches.json index d021de0d..de859c09 100644 --- a/github-rulesets/python/Protect version branches.json +++ b/github-rulesets/python/Protect version branches.json @@ -27,6 +27,7 @@ "require_last_push_approval": true, "dismiss_stale_reviews_on_push": true, "required_approving_review_count": 1, + "required_reviewers": [], "required_review_thread_resolution": false, "allowed_merge_methods": [ "merge", From 6779b97516677aa1298451fc157cc4679770a3cd Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Wed, 4 Mar 2026 09:35:23 +0100 Subject: [PATCH 4/9] rulesets: Don't require code owner review Code owners are good to request automatic review, but in practice it prevented some people with write access to approve PRs, which can be annoying. This is also necessary for the auto-dependabot and repo-config-migrate workflows to be able to approve and merge PRs automatically. Signed-off-by: Leandro Lucarella --- github-rulesets/python/Protect version branches.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/github-rulesets/python/Protect version branches.json b/github-rulesets/python/Protect version branches.json index de859c09..876757db 100644 --- a/github-rulesets/python/Protect version branches.json +++ b/github-rulesets/python/Protect version branches.json @@ -23,7 +23,7 @@ { "type": "pull_request", "parameters": { - "require_code_owner_review": true, + "require_code_owner_review": false, "require_last_push_approval": true, "dismiss_stale_reviews_on_push": true, "required_approving_review_count": 1, From de803bfe84eb77a1ada40e4b348359f0175ef5c2 Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Wed, 4 Mar 2026 09:36:26 +0100 Subject: [PATCH 5/9] rulesets: Require "Migration Repo Config" check We want to make sure that merges are blocked if the repo config migration failed/requires manual intervention. Signed-off-by: Leandro Lucarella --- github-rulesets/python/Protect version branches.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/github-rulesets/python/Protect version branches.json b/github-rulesets/python/Protect version branches.json index 876757db..a62a4944 100644 --- a/github-rulesets/python/Protect version branches.json +++ b/github-rulesets/python/Protect version branches.json @@ -55,6 +55,10 @@ { "context": "Check release notes are updated", "integration_id": 15368 + }, + { + "context": "Migrate Repo Config", + "integration_id": 15368 } ], "strict_required_status_checks_policy": false From cc465a6c7925d7ff767985856b52aa4dfe08c1e7 Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Wed, 4 Mar 2026 12:29:08 +0100 Subject: [PATCH 6/9] migrate: Ignore the `too-many-lines` check It is OK for the migration script to be long. Signed-off-by: Leandro Lucarella --- .github/cookiecutter-migrate.template.py | 2 ++ cookiecutter/migrate.py | 2 ++ 2 files changed, 4 insertions(+) diff --git a/.github/cookiecutter-migrate.template.py b/.github/cookiecutter-migrate.template.py index 155f8c70..e93fa4f3 100644 --- a/.github/cookiecutter-migrate.template.py +++ b/.github/cookiecutter-migrate.template.py @@ -20,6 +20,8 @@ And remember to follow any manual instructions for each run. """ # noqa: E501 +# pylint: disable=too-many-lines + import hashlib import json import os diff --git a/cookiecutter/migrate.py b/cookiecutter/migrate.py index 6b5f86a3..3f244c70 100644 --- a/cookiecutter/migrate.py +++ b/cookiecutter/migrate.py @@ -20,6 +20,8 @@ And remember to follow any manual instructions for each run. """ # noqa: E501 +# pylint: disable=too-many-lines + import hashlib import json import os From 8c903d6dcf8812e9d5edf23e23036886ff8b0d65 Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Wed, 4 Mar 2026 10:49:30 +0000 Subject: [PATCH 7/9] migrate: Update Protect version branches ruleset Add a migration step that updates the "Protect version branches" GitHub ruleset from migrate.py. Signed-off-by: Leandro Lucarella --- cookiecutter/migrate.py | 216 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 215 insertions(+), 1 deletion(-) diff --git a/cookiecutter/migrate.py b/cookiecutter/migrate.py index 3f244c70..ab9fa925 100644 --- a/cookiecutter/migrate.py +++ b/cookiecutter/migrate.py @@ -30,7 +30,7 @@ import sys import tempfile from pathlib import Path -from typing import SupportsIndex +from typing import Any, SupportsIndex _manual_steps: list[str] = [] # pylint: disable=invalid-name @@ -60,6 +60,9 @@ def main() -> None: print("Installing repo-config migration workflow...") migrate_repo_config_workflow() print("=" * 72) + print("Updating 'Protect version branches' GitHub ruleset...") + migrate_protect_version_branches_ruleset() + print("=" * 72) print() if _manual_steps: @@ -649,6 +652,217 @@ def migrate_repo_config_workflow() -> None: ) +def migrate_protect_version_branches_ruleset() -> None: + """Update the 'Protect version branches' GitHub ruleset. + + Uses the GitHub API (via ``gh`` CLI) to check whether the + 'Protect version branches' ruleset on the current repository is aligned + with the current template. Recent template changes include: + + * Setting ``require_code_owner_review`` to ``false``. + * Adding an (empty) ``required_reviewers`` list. + * Removing the ``automatic_copilot_code_review_enabled`` setting. + * Adding ``Migrate Repo Config`` to the required status checks. + * Setting the ``OrganizationAdmin`` bypass-actor ``actor_id`` to + ``null``. + + If the ruleset is already aligned, prints an informational message. + If it needs updating, applies the changes via the API without removing + any existing required status checks. + If the ruleset is not found at all, issues a manual-step message that + points the user to the docs. + """ + rule_name = "Protect version branches" + docs_url = ( + "https://frequenz-floss.github.io/frequenz-repo-config-python/" + "user-guide/start-a-new-project/configure-github/#rulesets" + ) + + # Build a link to the repo's ruleset settings for manual-step messages. + ruleset_url = get_ruleset_settings_url() or docs_url + + # ── Fetch ruleset details ──────────────────────────────────────── + ruleset = get_ruleset(rule_name) + if ruleset is None: + manual_step( + f"The '{rule_name}' GitHub ruleset was not found (or the gh CLI " + "is not available / the API call failed). " + "Please check whether it should exist for this repository. " + f"If it should, import it following the instructions at: {docs_url}" + ) + return + + ruleset_id = ruleset.get("id") + if not isinstance(ruleset_id, int): + manual_step( + f"Failed to determine the '{rule_name}' ruleset ID from the " + f"GitHub API response. Please update it manually at: {ruleset_url}" + ) + return + + # ── Detect and apply changes in-memory ─────────────────────────────── + changes: list[str] = [] + + for rule in ruleset.get("rules", []): + if rule.get("type") == "pull_request": + params = rule.setdefault("parameters", {}) + if params.get("require_code_owner_review") is True: + params["require_code_owner_review"] = False + changes.append("set require_code_owner_review=false") + if params.pop("automatic_copilot_code_review_enabled", None) is not None: + changes.append("remove automatic_copilot_code_review_enabled") + + elif rule.get("type") == "required_status_checks": + params = rule.setdefault("parameters", {}) + checks = params.setdefault("required_status_checks", []) + if not any(c.get("context") == "Migrate Repo Config" for c in checks): + checks.append( + {"context": "Migrate Repo Config", "integration_id": 15368} + ) + changes.append("add 'Migrate Repo Config' status check") + + if not changes: + print(f" Ruleset '{rule_name}' is already up to date") + return + + # ── Push the update ─────────────────────────────────────────────────── + if not update_ruleset(ruleset_id, ruleset): + manual_step( + f"Failed to update the '{rule_name}' ruleset via the GitHub API. " + f"Please apply the following changes manually at {ruleset_url}: " + + "; ".join(changes) + ) + return + + print(f" Updated ruleset '{rule_name}': " + ", ".join(changes)) + + +def find_ruleset(name: str) -> dict[str, Any] | None: + """Find a repository ruleset by name using the GitHub API. + + Args: + name: The name of the ruleset to search for. + + Returns: + The ruleset summary dict (id, name, …) if found, or ``None`` if not + found or if the API call failed (a diagnostic is printed in the latter + case). + """ + try: + stdout = subprocess.check_output( + ["gh", "api", "repos/:owner/:repo/rulesets"], + text=True, + stderr=subprocess.PIPE, + ) + except FileNotFoundError: + print(" gh CLI not found; cannot query rulesets via the GitHub API.") + return None + except subprocess.CalledProcessError as exc: + print(f" Failed to list rulesets: {exc.stderr.strip()}") + return None + + rulesets: list[dict[str, Any]] = json.loads(stdout) + return next((r for r in rulesets if r.get("name") == name), None) + + +def get_ruleset(ruleset: str | int) -> dict[str, Any] | None: + """Fetch the full details of a repository ruleset by name or ID. + + Args: + ruleset: The ruleset name (``str``) or numeric ruleset ID (``int``). + + Returns: + The full ruleset dict, or ``None`` if the ruleset could not be found + or the API call failed (a diagnostic is printed). + """ + ruleset_id = ruleset + if isinstance(ruleset, str): + entry = find_ruleset(ruleset) + if entry is None: + return None + ruleset_id = entry["id"] + + try: + stdout = subprocess.check_output( + ["gh", "api", f"repos/:owner/:repo/rulesets/{ruleset_id}"], + text=True, + stderr=subprocess.PIPE, + ) + except subprocess.CalledProcessError as exc: + print(f" Failed to fetch ruleset {ruleset_id}: {exc.stderr.strip()}") + return None + + return json.loads(stdout) # type: ignore[no-any-return] + + +def update_ruleset(ruleset_id: int, config: dict[str, Any]) -> bool: + """Update a repository ruleset via the GitHub API. + + Only ``name``, ``target``, ``enforcement``, ``conditions``, ``rules``, + and ``bypass_actors`` are sent (explicit allowlist to avoid sending + read-only fields back to the API). + + Args: + ruleset_id: The numeric ruleset ID to update. + config: The full ruleset dict (as returned by :func:`get_ruleset`) + with the desired changes already applied in-memory. + + Returns: + ``True`` on success, ``False`` if the API call failed (a diagnostic + is printed). + """ + payload: dict[str, Any] = { + "name": config["name"], + "target": config["target"], + "enforcement": config["enforcement"], + "conditions": config["conditions"], + "rules": config["rules"], + } + if "bypass_actors" in config: + payload["bypass_actors"] = config["bypass_actors"] + + try: + subprocess.check_output( + [ + "gh", + "api", + "-X", + "PUT", + f"repos/:owner/:repo/rulesets/{ruleset_id}", + "--input", + "-", + ], + input=json.dumps(payload), + text=True, + stderr=subprocess.PIPE, + ) + except subprocess.CalledProcessError as exc: + print(f" Failed to update ruleset {ruleset_id}: {exc.stderr.strip()}") + return False + + return True + + +def get_ruleset_settings_url() -> str | None: + """Return the URL to the repository's ruleset settings page. + + Returns: + The URL as a string, or ``None`` if it could not be determined. + """ + try: + stdout = subprocess.check_output( + ["gh", "repo", "view", "--json", "owner,name"], + text=True, + stderr=subprocess.PIPE, + ) + info: dict[str, Any] = json.loads(stdout) + org = info["owner"]["login"] + repo = info["name"] + return f"https://github.com/{org}/{repo}/settings/rules" + except (subprocess.CalledProcessError, KeyError, json.JSONDecodeError): + return None + + def read_project_type() -> str | None: """Read the cookiecutter project type from the replay file.""" replay_path = Path(".cookiecutter-replay.json") From 6c18a32509992c6316ee3fe852d490c07c1d0386 Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Wed, 4 Mar 2026 10:49:22 +0000 Subject: [PATCH 8/9] migrate: Add ruleset helpers to the template Copy the reusable GitHub ruleset helper functions to the migration script template so future migration steps can query and update rulesets without duplicating API plumbing. Signed-off-by: Leandro Lucarella --- .github/cookiecutter-migrate.template.py | 128 ++++++++++++++++++++++- 1 file changed, 127 insertions(+), 1 deletion(-) diff --git a/.github/cookiecutter-migrate.template.py b/.github/cookiecutter-migrate.template.py index e93fa4f3..10c689ec 100644 --- a/.github/cookiecutter-migrate.template.py +++ b/.github/cookiecutter-migrate.template.py @@ -29,7 +29,7 @@ import sys import tempfile from pathlib import Path -from typing import SupportsIndex +from typing import Any, SupportsIndex _manual_steps: list[str] = [] # pylint: disable=invalid-name @@ -181,6 +181,132 @@ def calculate_file_sha256_skip_lines(filepath: Path, skip_lines: int) -> str | N return hashlib.sha256(remaining_content.encode()).hexdigest() +def find_ruleset(name: str) -> dict[str, Any] | None: + """Find a repository ruleset by name using the GitHub API. + + Args: + name: The name of the ruleset to search for. + + Returns: + The ruleset summary dict (id, name, …) if found, or ``None`` if not + found or if the API call failed (a diagnostic is printed in the latter + case). + """ + try: + stdout = subprocess.check_output( + ["gh", "api", "repos/:owner/:repo/rulesets"], + text=True, + stderr=subprocess.PIPE, + ) + except FileNotFoundError: + print(" gh CLI not found; cannot query rulesets via the GitHub API.") + return None + except subprocess.CalledProcessError as exc: + print(f" Failed to list rulesets: {exc.stderr.strip()}") + return None + + rulesets: list[dict[str, Any]] = json.loads(stdout) + return next((r for r in rulesets if r.get("name") == name), None) + + +def get_ruleset(ruleset: str | int) -> dict[str, Any] | None: + """Fetch the full details of a repository ruleset by name or ID. + + Args: + ruleset: The ruleset name (``str``) or numeric ruleset ID (``int``). + + Returns: + The full ruleset dict, or ``None`` if the ruleset could not be found + or the API call failed (a diagnostic is printed). + """ + ruleset_id = ruleset + if isinstance(ruleset, str): + entry = find_ruleset(ruleset) + if entry is None: + return None + ruleset_id = entry["id"] + + try: + stdout = subprocess.check_output( + ["gh", "api", f"repos/:owner/:repo/rulesets/{ruleset_id}"], + text=True, + stderr=subprocess.PIPE, + ) + except subprocess.CalledProcessError as exc: + print(f" Failed to fetch ruleset {ruleset_id}: {exc.stderr.strip()}") + return None + + return json.loads(stdout) # type: ignore[no-any-return] + + +def update_ruleset(ruleset_id: int, config: dict[str, Any]) -> bool: + """Update a repository ruleset via the GitHub API. + + Only ``name``, ``target``, ``enforcement``, ``conditions``, ``rules``, + and ``bypass_actors`` are sent (explicit allowlist to avoid sending + read-only fields back to the API). + + Args: + ruleset_id: The numeric ruleset ID to update. + config: The full ruleset dict (as returned by :func:`get_ruleset`) + with the desired changes already applied in-memory. + + Returns: + ``True`` on success, ``False`` if the API call failed (a diagnostic + is printed). + """ + payload: dict[str, Any] = { + "name": config["name"], + "target": config["target"], + "enforcement": config["enforcement"], + "conditions": config["conditions"], + "rules": config["rules"], + } + if "bypass_actors" in config: + payload["bypass_actors"] = config["bypass_actors"] + + try: + subprocess.check_output( + [ + "gh", + "api", + "-X", + "PUT", + f"repos/:owner/:repo/rulesets/{ruleset_id}", + "--input", + "-", + ], + input=json.dumps(payload), + text=True, + stderr=subprocess.PIPE, + ) + except subprocess.CalledProcessError as exc: + print(f" Failed to update ruleset {ruleset_id}: {exc.stderr.strip()}") + return False + + return True + + +def get_ruleset_settings_url() -> str | None: + """Return the URL to the repository's ruleset settings page. + + Returns: + The URL as a string, or ``None`` if it could not be determined. + """ + try: + stdout = subprocess.check_output( + ["gh", "repo", "view", "--json", "owner,name"], + text=True, + stderr=subprocess.PIPE, + ) + info: dict[str, Any] = json.loads(stdout) + org = info["owner"]["login"] + repo = info["name"] + return f"https://github.com/{org}/{repo}/settings/rules" + except (subprocess.CalledProcessError, KeyError, json.JSONDecodeError): + return None + + def manual_step(message: str) -> None: """Print a manual step message in yellow.""" _manual_steps.append(message) From 8cf7819cd28e857205cbba899221ef59185df5fa Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Wed, 4 Mar 2026 12:10:49 +0100 Subject: [PATCH 9/9] Add changes to rulesets to the release notes Signed-off-by: Leandro Lucarella --- RELEASE_NOTES.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 0215809f..425c4c03 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -48,6 +48,8 @@ But you might still need to adapt your code: - The `auto-dependabot.yaml` workflow now skips repo-config group PRs, which are handled by the new migration workflow instead. +- Updated the default "Protect version branches" ruleset to require the new `Migrate Repo Config` status check, which is added by the migration workflow to PRs that need manual intervention. This prevents merging PRs that require manual migration steps until those steps are completed and the check passes. Also removed the required code-owner approval and automatic Copilot review request. + ## Bug Fixes