From 53285fb846c5a25a527b9946d24cedf1fd6178d3 Mon Sep 17 00:00:00 2001 From: Kelly Sovacool Date: Thu, 25 Jun 2026 14:04:17 -0400 Subject: [PATCH 01/14] chore: ignore .posit/ --- .gitignore | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 132f11e..aa7d8dd 100644 --- a/.gitignore +++ b/.gitignore @@ -161,10 +161,12 @@ cython_debug/ # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ +# quarto /.quarto/ /_site/ /objects.json /reference /_sidebar.yml - **/*.quarto_ipynb + +/.posit/ From 5ee2b1fcf2aa8c2a956f825938229b6b77194497 Mon Sep 17 00:00:00 2001 From: Kelly Sovacool Date: Thu, 25 Jun 2026 14:35:04 -0400 Subject: [PATCH 02/14] feat: cli cmd + action to copy repo rulesets --- copy-ruleset/README.qmd | 68 +++++++++++++ copy-ruleset/action.yml | 62 ++++++++++++ examples/copy-ruleset.yml | 28 ++++++ src/ccbr_actions/__main__.py | 78 ++++++++++++++- src/ccbr_actions/github.py | 88 +++++++++++++++++ tests/test_cli.py | 132 +++++++++++++++++++++++++ tests/test_github.py | 184 +++++++++++++++++++++++++++++++++++ 7 files changed, 637 insertions(+), 3 deletions(-) create mode 100644 copy-ruleset/README.qmd create mode 100644 copy-ruleset/action.yml create mode 100644 examples/copy-ruleset.yml diff --git a/copy-ruleset/README.qmd b/copy-ruleset/README.qmd new file mode 100644 index 0000000..a54532e --- /dev/null +++ b/copy-ruleset/README.qmd @@ -0,0 +1,68 @@ +--- +title: copy-ruleset +format: gfm +execute: + echo: false + output: asis +--- + +```{python} +import ccbr_actions.docs + +action = ccbr_actions.docs.parse_action_yaml('action.yml') +print(ccbr_actions.docs.action_markdown_desc(action)) +``` + +Copy a named ruleset from one GitHub repository to another. +This action fetches the full ruleset definition from the source repository via the GitHub API and creates an identical ruleset in the target repository. +It is useful for standardising branch protection rules across multiple repositories in an organisation. + +> **Tip:** Not sure what rulesets exist in a repository? +> Run `ccbr_actions list-rulesets owner/repo` locally to see all available ruleset names and their enforcement status. + +## Usage + +### Basic example + +[copy-ruleset.yml](/examples/copy-ruleset.yml) + +```{python} +yml_file = '../examples/copy-ruleset.yml' +print('```yaml') +with open(yml_file, 'r') as infile: + print(infile.read()) +print('```\n') +``` + +### Using a PAT for cross-repository access + +`github.token` is scoped to the repository running the workflow. +To copy rulesets to a repository outside the current one, supply a personal access token (PAT) or a GitHub App token with `repo` scope on both repositories: + +```yaml +steps: + - uses: CCBR/actions/copy-ruleset@main + with: + source-repo: CCBR/actions + target-repo: CCBR/other-repo + ruleset-name: "Require PR reviews" + token: ${{ secrets.ORG_PAT }} +``` + +### Pinning versions + +```yaml +steps: + - uses: CCBR/actions/copy-ruleset@main + with: + source-repo: CCBR/actions + target-repo: CCBR/other-repo + ruleset-name: "Require PR reviews" + token: ${{ secrets.ORG_PAT }} + python-version: "3.12" + ccbr-actions-version: "v1.0.0" +``` + +```{python} +print(ccbr_actions.docs.action_markdown_io(action)) +``` diff --git a/copy-ruleset/action.yml b/copy-ruleset/action.yml new file mode 100644 index 0000000..ed0510f --- /dev/null +++ b/copy-ruleset/action.yml @@ -0,0 +1,62 @@ +name: "Copy Ruleset" +author: "Kelly Sovacool" + +description: | + Copy a repository ruleset from one GitHub repository to another. + +branding: + icon: copy + color: purple + +inputs: + source-repo: + description: "Source repository in owner/repo format." + required: true + target-repo: + description: "Target repository in owner/repo format." + required: true + ruleset-name: + description: "Name of the ruleset to copy." + required: true + token: + description: "GitHub token with repo scope on both repositories." + required: false + default: "${{ github.token }}" + python-version: + description: "The version of Python to install." + required: false + default: "3.11" + ccbr-actions-version: + description: "The version of ccbr_actions to use." + required: false + default: "main" + +runs: + using: composite + steps: + - uses: actions/setup-python@v6 + with: + python-version: "${{ inputs.python-version }}" + + - name: Install ccbr_actions + shell: bash + run: python -m pip install --upgrade pip "git+https://github.com/CCBR/actions.git@${{ inputs.ccbr-actions-version }}" + + - name: Copy ruleset + shell: python + env: + GH_TOKEN: "${{ inputs.token }}" + SOURCE_REPO: "${{ inputs.source-repo }}" + TARGET_REPO: "${{ inputs.target-repo }}" + RULESET_NAME: "${{ inputs.ruleset-name }}" + run: | + import os + from ccbr_actions.github import copy_ruleset + + result = copy_ruleset( + source_repo=os.environ["SOURCE_REPO"], + target_repo=os.environ["TARGET_REPO"], + ruleset_name=os.environ["RULESET_NAME"], + token=os.environ.get("GH_TOKEN", ""), + ) + print(f"Ruleset '{result['name']}' (id={result['id']}) created in {os.environ['TARGET_REPO']}.") diff --git a/examples/copy-ruleset.yml b/examples/copy-ruleset.yml new file mode 100644 index 0000000..a25b6b4 --- /dev/null +++ b/examples/copy-ruleset.yml @@ -0,0 +1,28 @@ +name: copy-ruleset + +on: + workflow_dispatch: + inputs: + source-repo: + description: "Source repository (owner/repo) to copy the ruleset from." + required: true + target-repo: + description: "Target repository (owner/repo) to copy the ruleset to." + required: true + ruleset-name: + description: "Name of the ruleset to copy." + required: true + +permissions: + contents: read + +jobs: + copy-ruleset: + runs-on: ubuntu-latest + steps: + - uses: CCBR/actions/copy-ruleset@latest + with: + source-repo: ${{ inputs.source-repo }} + target-repo: ${{ inputs.target-repo }} + ruleset-name: ${{ inputs.ruleset-name }} + token: ${{ secrets.ORG_PAT }} diff --git a/src/ccbr_actions/__main__.py b/src/ccbr_actions/__main__.py index feb7434..77703b4 100644 --- a/src/ccbr_actions/__main__.py +++ b/src/ccbr_actions/__main__.py @@ -8,6 +8,7 @@ from .util import repo_base, print_citation from .actions import use_github_action +from .github import copy_ruleset, list_rulesets @click.group( @@ -59,12 +60,83 @@ def use_example(name): cli.add_command(use_example) +@click.command() +@click.argument("repo") +@click.option( + "--token", + "-t", + envvar="GH_TOKEN", + default=None, + help="GitHub token with repo scope. Defaults to the GH_TOKEN environment variable.", +) +def list_rulesets_cmd(repo, token): + """ + List all rulesets for a GitHub repository. + + \b + Args: + repo (str): Repository in owner/repo format. + + \b + Examples: + ccbr_actions list-rulesets CCBR/actions + ccbr_actions list-rulesets CCBR/actions --token ghp_... + """ + rulesets = list_rulesets(repo=repo, token=token) + if not rulesets: + click.echo(f"No rulesets found in {repo}.") + return + for r in rulesets: + click.echo(f"{r['id']:>10} {r['enforcement']:<12} {r['name']}") + + +cli.add_command(list_rulesets_cmd, name="list-rulesets") + + +@click.command() +@click.argument("source-repo") +@click.argument("target-repo") +@click.argument("ruleset-name") +@click.option( + "--token", + "-t", + envvar="GH_TOKEN", + default=None, + help="GitHub token with repo scope. Defaults to the GH_TOKEN environment variable.", +) +def copy_ruleset_cmd(source_repo, target_repo, ruleset_name, token): + """ + Copy a ruleset from one GitHub repository to another. + + \b + Args: + source-repo (str): Source repository in owner/repo format. + target-repo (str): Target repository in owner/repo format. + ruleset-name (str): Name of the ruleset to copy. + + \b + Examples: + ccbr_actions copy-ruleset CCBR/actions CCBR/other-repo "Require PR reviews" + ccbr_actions copy-ruleset CCBR/actions CCBR/other-repo "Require PR reviews" --token ghp_... + """ + result = copy_ruleset( + source_repo=source_repo, + target_repo=target_repo, + ruleset_name=ruleset_name, + token=token, + ) + click.echo( + f"Ruleset '{result['name']}' (id={result['id']}) created in {target_repo}." + ) + + +cli.add_command(copy_ruleset_cmd, name="copy-ruleset") + + def main(): """Run the Click CLI entry point.""" cli() -cli(prog_name="ccbr_actions") - if __name__ == "__main__": - main() + cli(prog_name="ccbr_actions") diff --git a/src/ccbr_actions/github.py b/src/ccbr_actions/github.py index 0a041dc..db8dff5 100644 --- a/src/ccbr_actions/github.py +++ b/src/ccbr_actions/github.py @@ -94,3 +94,91 @@ def github_api_post(url, token=None, session=requests, **kwargs): return github_api_request( method="POST", url=url, token=token, session=session, **kwargs ) + + +def list_rulesets(repo, token=None, session=requests): + """ + List all rulesets for a GitHub repository. + + Args: + repo (str): Repository in ``owner/repo`` format. + token (str, optional): GitHub token with ``repo`` scope. + session: Object with a requests-compatible ``request`` method. + + Returns: + list[dict]: Rulesets as returned by the GitHub API. Each item + contains at minimum ``id``, ``name``, and ``enforcement``. + + Raises: + requests.HTTPError: If the GitHub API request fails. + + Examples: + >>> list_rulesets("CCBR/actions", token="ghp_...") + """ + url = f"{GITHUB_API_URL}/repos/{repo}/rulesets" + return github_api_get(url, token=token, session=session) + + +def copy_ruleset( + source_repo, + target_repo, + ruleset_name, + token=None, + session=requests, +): + """ + Copy a ruleset from one GitHub repository to another. + + Fetches all rulesets from ``source_repo``, finds the one matching + ``ruleset_name``, and creates an identical ruleset in ``target_repo``. + + Args: + source_repo (str): Source repository in ``owner/repo`` format. + target_repo (str): Target repository in ``owner/repo`` format. + ruleset_name (str): Name of the ruleset to copy. + token (str, optional): GitHub token with ``repo`` scope. + session: Object with a requests-compatible ``request`` method. + + Returns: + dict: The created ruleset as returned by the GitHub API. + + Raises: + ValueError: If no ruleset with ``ruleset_name`` is found in + ``source_repo``. + requests.HTTPError: If any GitHub API request fails. + + Examples: + >>> copy_ruleset( + ... source_repo="CCBR/actions", + ... target_repo="CCBR/other-repo", + ... ruleset_name="Require PR reviews", + ... token="ghp_...", + ... ) + """ + # Fetch all rulesets from the source repository + list_url = f"{GITHUB_API_URL}/repos/{source_repo}/rulesets" + rulesets = github_api_get(list_url, token=token, session=session) + + # Find the matching ruleset by name + match = next((r for r in rulesets if r.get("name") == ruleset_name), None) + if match is None: + available = [r.get("name") for r in rulesets] + raise ValueError( + f"Ruleset {ruleset_name!r} not found in {source_repo!r}. " + f"Available rulesets: {available}" + ) + + # Fetch the full ruleset definition (the list endpoint omits some fields) + ruleset_id = match["id"] + detail_url = f"{GITHUB_API_URL}/repos/{source_repo}/rulesets/{ruleset_id}" + ruleset = github_api_get(detail_url, token=token, session=session) + + # Build the payload for the target repository, dropping read-only fields + _read_only_fields = {"id", "node_id", "created_at", "updated_at", "_links"} + payload = {k: v for k, v in ruleset.items() if k not in _read_only_fields} + + # Create the ruleset in the target repository + create_url = f"{GITHUB_API_URL}/repos/{target_repo}/rulesets" + response = github_api_post(create_url, token=token, session=session, json=payload) + response.raise_for_status() + return response.json() diff --git a/tests/test_cli.py b/tests/test_cli.py index 04f487b..ad398c4 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,6 +1,9 @@ import os import pathlib +from unittest.mock import patch +from click.testing import CliRunner from ccbr_tools.shell import shell_run +from ccbr_actions.__main__ import cli def test_citation(): @@ -20,3 +23,132 @@ def test_use_example(tmp_path): finally: os.chdir(current_wd) assert outfile.exists() + + +# --------------------------------------------------------------------------- +# list-rulesets CLI +# --------------------------------------------------------------------------- + +MOCK_RULESETS = [ + {"id": 1, "name": "Require PR reviews", "enforcement": "active"}, + {"id": 2, "name": "Restrict force push", "enforcement": "evaluate"}, +] + + +def test_list_rulesets_prints_rulesets(): + runner = CliRunner() + with patch("ccbr_actions.__main__.list_rulesets", return_value=MOCK_RULESETS): + result = runner.invoke(cli, ["list-rulesets", "CCBR/actions"]) + + assert result.exit_code == 0 + assert "Require PR reviews" in result.output + assert "Restrict force push" in result.output + assert "active" in result.output + assert "evaluate" in result.output + + +def test_list_rulesets_prints_message_when_empty(): + runner = CliRunner() + with patch("ccbr_actions.__main__.list_rulesets", return_value=[]): + result = runner.invoke(cli, ["list-rulesets", "CCBR/empty-repo"]) + + assert result.exit_code == 0 + assert "No rulesets found" in result.output + + +def test_list_rulesets_passes_token(): + runner = CliRunner() + with patch("ccbr_actions.__main__.list_rulesets", return_value=[]) as mock_fn: + runner.invoke(cli, ["list-rulesets", "CCBR/actions", "--token", "ghp_test"]) + + mock_fn.assert_called_once_with(repo="CCBR/actions", token="ghp_test") + + +def test_list_rulesets_reads_token_from_env(): + runner = CliRunner() + with patch("ccbr_actions.__main__.list_rulesets", return_value=[]) as mock_fn: + result = runner.invoke( + cli, ["list-rulesets", "CCBR/actions"], env={"GH_TOKEN": "ghp_env"} + ) + + assert result.exit_code == 0 + mock_fn.assert_called_once_with(repo="CCBR/actions", token="ghp_env") + + +# --------------------------------------------------------------------------- +# copy-ruleset CLI +# --------------------------------------------------------------------------- + +CREATED_RULESET = {"id": 99, "name": "Require PR reviews"} + + +def test_copy_ruleset_prints_confirmation(): + runner = CliRunner() + with patch("ccbr_actions.__main__.copy_ruleset", return_value=CREATED_RULESET): + result = runner.invoke( + cli, + ["copy-ruleset", "CCBR/actions", "CCBR/other-repo", "Require PR reviews"], + ) + + assert result.exit_code == 0 + assert "Require PR reviews" in result.output + assert "99" in result.output + assert "CCBR/other-repo" in result.output + + +def test_copy_ruleset_passes_arguments(): + runner = CliRunner() + with patch( + "ccbr_actions.__main__.copy_ruleset", return_value=CREATED_RULESET + ) as mock_fn: + runner.invoke( + cli, + [ + "copy-ruleset", + "CCBR/actions", + "CCBR/other-repo", + "Require PR reviews", + "--token", + "ghp_test", + ], + ) + + mock_fn.assert_called_once_with( + source_repo="CCBR/actions", + target_repo="CCBR/other-repo", + ruleset_name="Require PR reviews", + token="ghp_test", + ) + + +def test_copy_ruleset_reads_token_from_env(): + runner = CliRunner() + with patch( + "ccbr_actions.__main__.copy_ruleset", return_value=CREATED_RULESET + ) as mock_fn: + runner.invoke( + cli, + ["copy-ruleset", "CCBR/actions", "CCBR/other-repo", "Require PR reviews"], + env={"GH_TOKEN": "ghp_env"}, + ) + + mock_fn.assert_called_once_with( + source_repo="CCBR/actions", + target_repo="CCBR/other-repo", + ruleset_name="Require PR reviews", + token="ghp_env", + ) + + +def test_copy_ruleset_surfaces_value_error(): + runner = CliRunner() + with patch( + "ccbr_actions.__main__.copy_ruleset", + side_effect=ValueError("'Nonexistent' not found"), + ): + result = runner.invoke( + cli, + ["copy-ruleset", "CCBR/actions", "CCBR/other-repo", "Nonexistent"], + ) + + assert result.exit_code != 0 diff --git a/tests/test_github.py b/tests/test_github.py index 5c79978..4339546 100644 --- a/tests/test_github.py +++ b/tests/test_github.py @@ -1,8 +1,11 @@ +import pytest from ccbr_actions.github import ( github_api_get, github_api_headers, github_api_post, github_api_request, + list_rulesets, + copy_ruleset, ) @@ -85,3 +88,184 @@ def test_github_api_post_supports_method_only_interface(): assert response.status_code == 201 assert session.calls[0][0] == "POST" + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +RULESET_SUMMARY = [ + {"id": 1, "name": "Require PR reviews", "enforcement": "active"}, + {"id": 2, "name": "Restrict force push", "enforcement": "evaluate"}, +] + +RULESET_DETAIL = { + "id": 1, + "node_id": "abc123", + "name": "Require PR reviews", + "enforcement": "active", + "created_at": "2024-01-01T00:00:00Z", + "updated_at": "2024-01-02T00:00:00Z", + "_links": { + "self": {"href": "https://api.github.com/repos/CCBR/actions/rulesets/1"} + }, + "rules": [{"type": "pull_request"}], + "conditions": {"ref_name": {"include": ["~DEFAULT_BRANCH"], "exclude": []}}, +} + +CREATED_RULESET = { + "id": 99, + "name": "Require PR reviews", + "enforcement": "active", + "rules": [{"type": "pull_request"}], + "conditions": {"ref_name": {"include": ["~DEFAULT_BRANCH"], "exclude": []}}, +} + + +class RulesetMockSession: + """Mock session that serves realistic ruleset API responses.""" + + def __init__(self): + self.calls = [] + + def get(self, url, headers=None, **kwargs): + self.calls.append(("GET", url)) + if url.endswith("/rulesets"): + return MockResponse(RULESET_SUMMARY) + if "/rulesets/1" in url: # abs-path:ignore + return MockResponse(RULESET_DETAIL) + return MockResponse([], status_code=404) + + def post(self, url, headers=None, **kwargs): + self.calls.append(("POST", url, kwargs.get("json"))) + return MockResponse(CREATED_RULESET, status_code=201) + + +# --------------------------------------------------------------------------- +# list_rulesets +# --------------------------------------------------------------------------- + + +def test_list_rulesets_returns_list(): + session = RulesetMockSession() + + result = list_rulesets(repo="CCBR/actions", session=session) + + assert result == RULESET_SUMMARY + + +def test_list_rulesets_calls_correct_url(): + session = RulesetMockSession() + + list_rulesets(repo="CCBR/actions", session=session) + + method, url = session.calls[0] + assert method == "GET" + assert url == "https://api.github.com/repos/CCBR/actions/rulesets" + + +def test_list_rulesets_returns_empty_list_when_none(): + class EmptySession: + def get(self, url, headers=None, **kwargs): + return MockResponse([]) + + result = list_rulesets(repo="CCBR/empty-repo", session=EmptySession()) + + assert result == [] + + +# --------------------------------------------------------------------------- +# copy_ruleset +# --------------------------------------------------------------------------- + + +def test_copy_ruleset_returns_created_ruleset(): + session = RulesetMockSession() + + result = copy_ruleset( + source_repo="CCBR/actions", + target_repo="CCBR/other-repo", + ruleset_name="Require PR reviews", + session=session, + ) + + assert result == CREATED_RULESET + + +def test_copy_ruleset_strips_read_only_fields(): + session = RulesetMockSession() + + copy_ruleset( + source_repo="CCBR/actions", + target_repo="CCBR/other-repo", + ruleset_name="Require PR reviews", + session=session, + ) + + # Third call is POST; inspect the payload sent + _, _url, payload = session.calls[2] + for field in ("id", "node_id", "created_at", "updated_at", "_links"): + assert field not in payload, f"Read-only field '{field}' should be stripped" + + +def test_copy_ruleset_preserves_rules_and_conditions(): + session = RulesetMockSession() + + copy_ruleset( + source_repo="CCBR/actions", + target_repo="CCBR/other-repo", + ruleset_name="Require PR reviews", + session=session, + ) + + _, _url, payload = session.calls[2] + assert payload["rules"] == RULESET_DETAIL["rules"] + assert payload["conditions"] == RULESET_DETAIL["conditions"] + + +def test_copy_ruleset_calls_correct_urls(): + session = RulesetMockSession() + + copy_ruleset( + source_repo="CCBR/actions", + target_repo="CCBR/other-repo", + ruleset_name="Require PR reviews", + session=session, + ) + + assert session.calls[0] == ( + "GET", + "https://api.github.com/repos/CCBR/actions/rulesets", + ) + assert session.calls[1] == ( + "GET", + "https://api.github.com/repos/CCBR/actions/rulesets/1", + ) + assert session.calls[2][0] == "POST" + assert ( + session.calls[2][1] == "https://api.github.com/repos/CCBR/other-repo/rulesets" + ) + + +def test_copy_ruleset_raises_when_ruleset_not_found(): + session = RulesetMockSession() + + with pytest.raises(ValueError, match="'Nonexistent'"): + copy_ruleset( + source_repo="CCBR/actions", + target_repo="CCBR/other-repo", + ruleset_name="Nonexistent", + session=session, + ) + + +def test_copy_ruleset_error_includes_available_names(): + session = RulesetMockSession() + + with pytest.raises(ValueError, match="Require PR reviews"): + copy_ruleset( + source_repo="CCBR/actions", + target_repo="CCBR/other-repo", + ruleset_name="Nonexistent", + session=session, + ) From 4bcddd405faa0da58b0851e8cb3bb36d38d7abc4 Mon Sep 17 00:00:00 2001 From: Kelly Sovacool Date: Thu, 25 Jun 2026 14:50:14 -0400 Subject: [PATCH 03/14] chore: update CHANGELOG.md --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 53c5750..8a0deb1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ ## actions development version +- New command and action `copy-ruleset` for copying github rulesets between repositories. (#183, @kelly-sovacool) + ## actions 0.7.1 - Fix `draft-release` to preserve custom keys in `CITATION.cff` in R packages. (#180, @kelly-sovacool) From 4306cc3094b620bb980d348dba1b356a11278041 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 25 Jun 2026 18:50:58 +0000 Subject: [PATCH 04/14] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- SECURITY.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index 28421a0..99da43f 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,7 +2,7 @@ ## Maintained Versions -Actively maintained versions of contained software will vary from repository to repository, or may not be relevant at all. +Actively maintained versions of contained software will vary from repository to repository, or may not be relevant at all. The developers of this repository will update this section if any actively maintained versions of the software need to be publicly disclosed. Otherwise, contact the developers directly for any version information. ## Vulnerability Disclosure: @@ -16,4 +16,3 @@ Follow the instructions listed in the [HHS vulnerability disclosure policy](http 1. Click on the **Security and quality** tab of this repository. 2. Locate the **Report a vulnerability** button. If the button is not on the **Security and quality** landing page, look under the **Advisories** section in the side bar. 3. Click the **Report a vulnerability** button and submit the form. The developers will receive a notification of your submission. - From 104ffc8b4b63be50422faf44ebf4476cbd697771 Mon Sep 17 00:00:00 2001 From: CCBR-bot <258092125+ccbr-bot@users.noreply.github.com> Date: Thu, 25 Jun 2026 18:56:57 +0000 Subject: [PATCH 05/14] =?UTF-8?q?ci:=20=F0=9F=A4=96=20render=20readme=20&?= =?UTF-8?q?=20format=20everything=20with=20pre-commit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 46 ++++++++++++++++++++- copy-ruleset/README.md | 93 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 138 insertions(+), 1 deletion(-) create mode 100644 copy-ruleset/README.md diff --git a/README.md b/README.md index 48e6281..3d7f43b 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ them for your needs. - [build-snakemake](examples/build-snakemake.yml) - [changed-files](examples/changed-files.yml) - [check-links](examples/check-links.yml) +- [copy-ruleset](examples/copy-ruleset.yml) - [docs-mkdocs](examples/docs-mkdocs.yml) - [docs-quarto](examples/docs-quarto.yml) - [draft-release](examples/draft-release.yml) @@ -54,6 +55,8 @@ Custom actions used in our github workflows. guidelines - [Changed Files](changed-files) - Get a list of changed files and filter them by a list of path patterns similar to .gitignore +- [Copy Ruleset](copy-ruleset) - Copy a repository ruleset from one + GitHub repository to another. - [draft-release](draft-release) - Draft a new release based on conventional commits and prepare release notes - [Install R + pak](install-r-pak) - Install R package dependencies with @@ -115,7 +118,9 @@ pip install git+https://github.com/CCBR/actions@v0.7 -h, --help Show this message and exit. Commands: - use-example Use a GitHub Actions workflow file from CCBR/actions. + use-example Use a GitHub Actions workflow file from CCBR/actions. + list-rulesets List all rulesets for a GitHub repository. + copy-ruleset Copy a ruleset from one GitHub repository to another. #### use-example @@ -136,6 +141,45 @@ pip install git+https://github.com/CCBR/actions@v0.7 Options: -h, --help Show this message and exit. +#### list-rulesets + + Usage: ccbr_actions list-rulesets [OPTIONS] REPO + + List all rulesets for a GitHub repository. + + Args: + repo (str): Repository in owner/repo format. + + Examples: + ccbr_actions list-rulesets CCBR/actions + ccbr_actions list-rulesets CCBR/actions --token ghp_... + + Options: + -t, --token TEXT GitHub token with repo scope. Defaults to the GH_TOKEN + environment variable. + -h, --help Show this message and exit. + +#### copy-ruleset + + Usage: ccbr_actions copy-ruleset [OPTIONS] SOURCE_REPO TARGET_REPO + RULESET_NAME + + Copy a ruleset from one GitHub repository to another. + + Args: + source-repo (str): Source repository in owner/repo format. + target-repo (str): Target repository in owner/repo format. + ruleset-name (str): Name of the ruleset to copy. + + Examples: + ccbr_actions copy-ruleset CCBR/actions CCBR/other-repo "Require PR reviews" + ccbr_actions copy-ruleset CCBR/actions CCBR/other-repo "Require PR reviews" --token ghp_... + + Options: + -t, --token TEXT GitHub token with repo scope. Defaults to the GH_TOKEN + environment variable. + -h, --help Show this message and exit. + ## Help & Contributing Come across a **bug**? Open an diff --git a/copy-ruleset/README.md b/copy-ruleset/README.md new file mode 100644 index 0000000..f62858a --- /dev/null +++ b/copy-ruleset/README.md @@ -0,0 +1,93 @@ +# copy-ruleset + +**`Copy Ruleset`** - Copy a repository ruleset from one GitHub +repository to another. + +Copy a named ruleset from one GitHub repository to another. This action +fetches the full ruleset definition from the source repository via the +GitHub API and creates an identical ruleset in the target repository. It +is useful for standardising branch protection rules across multiple +repositories in an organisation. + +> **Tip:** Not sure what rulesets exist in a repository? Run +> `ccbr_actions list-rulesets owner/repo` locally to see all available +> ruleset names and their enforcement status. + +## Usage + +### Basic example + +[copy-ruleset.yml](/examples/copy-ruleset.yml) + +``` yaml +name: copy-ruleset + +on: + workflow_dispatch: + inputs: + source-repo: + description: "Source repository (owner/repo) to copy the ruleset from." + required: true + target-repo: + description: "Target repository (owner/repo) to copy the ruleset to." + required: true + ruleset-name: + description: "Name of the ruleset to copy." + required: true + +permissions: + contents: read + +jobs: + copy-ruleset: + runs-on: ubuntu-latest + steps: + - uses: CCBR/actions/copy-ruleset@latest + with: + source-repo: ${{ inputs.source-repo }} + target-repo: ${{ inputs.target-repo }} + ruleset-name: ${{ inputs.ruleset-name }} + token: ${{ secrets.ORG_PAT }} +``` + +### Using a PAT for cross-repository access + +`github.token` is scoped to the repository running the workflow. To copy +rulesets to a repository outside the current one, supply a personal +access token (PAT) or a GitHub App token with `repo` scope on both +repositories: + +``` yaml +steps: + - uses: CCBR/actions/copy-ruleset@main + with: + source-repo: CCBR/actions + target-repo: CCBR/other-repo + ruleset-name: "Require PR reviews" + token: ${{ secrets.ORG_PAT }} +``` + +### Pinning versions + +``` yaml +steps: + - uses: CCBR/actions/copy-ruleset@main + with: + source-repo: CCBR/actions + target-repo: CCBR/other-repo + ruleset-name: "Require PR reviews" + token: ${{ secrets.ORG_PAT }} + python-version: "3.12" + ccbr-actions-version: "v1.0.0" +``` + +## Inputs + +- `source-repo`: Source repository in owner/repo format. **Required.** +- `target-repo`: Target repository in owner/repo format. **Required.** +- `ruleset-name`: Name of the ruleset to copy. **Required.** +- `token`: GitHub token with repo scope on both repositories. Default: + `${{ github.token }}`. +- `python-version`: The version of Python to install. Default: `3.11`. +- `ccbr-actions-version`: The version of ccbr_actions to use. Default: + `main`. From 7afcf5ebea675f8241223c814d592d8b48f1c9cf Mon Sep 17 00:00:00 2001 From: CCBR-bot <258092125+ccbr-bot@users.noreply.github.com> Date: Thu, 25 Jun 2026 19:00:39 +0000 Subject: [PATCH 06/14] =?UTF-8?q?ci:=20=F0=9F=A4=96=20render=20readme=20&?= =?UTF-8?q?=20format=20everything=20with=20pre-commit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- copy-ruleset/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/copy-ruleset/README.md b/copy-ruleset/README.md index f62858a..5f5f728 100644 --- a/copy-ruleset/README.md +++ b/copy-ruleset/README.md @@ -19,7 +19,7 @@ repositories in an organisation. [copy-ruleset.yml](/examples/copy-ruleset.yml) -``` yaml +```yaml name: copy-ruleset on: @@ -57,7 +57,7 @@ rulesets to a repository outside the current one, supply a personal access token (PAT) or a GitHub App token with `repo` scope on both repositories: -``` yaml +```yaml steps: - uses: CCBR/actions/copy-ruleset@main with: @@ -69,7 +69,7 @@ steps: ### Pinning versions -``` yaml +```yaml steps: - uses: CCBR/actions/copy-ruleset@main with: From eee9822bc898984083990b12f3f414ef3f0f9645 Mon Sep 17 00:00:00 2001 From: "Kelly Sovacool, PhD" Date: Thu, 25 Jun 2026 15:11:00 -0400 Subject: [PATCH 07/14] chore: update changelog Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a0deb1..2c54e1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ ## actions development version -- New command and action `copy-ruleset` for copying github rulesets between repositories. (#183, @kelly-sovacool) +- New commands `list-rulesets` and `copy-ruleset`, plus a `copy-ruleset` action, for copying GitHub rulesets between repositories. (#183, @kelly-sovacool) ## actions 0.7.1 From 20c4a6aac37088f86f5d363722a71ad617ed6a28 Mon Sep 17 00:00:00 2001 From: "Kelly Sovacool, PhD" Date: Thu, 25 Jun 2026 15:11:59 -0400 Subject: [PATCH 08/14] docs: session param desc Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/ccbr_actions/github.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/ccbr_actions/github.py b/src/ccbr_actions/github.py index db8dff5..f90cc39 100644 --- a/src/ccbr_actions/github.py +++ b/src/ccbr_actions/github.py @@ -103,8 +103,8 @@ def list_rulesets(repo, token=None, session=requests): Args: repo (str): Repository in ``owner/repo`` format. token (str, optional): GitHub token with ``repo`` scope. - session: Object with a requests-compatible ``request`` method. - + session: Object with a requests-compatible ``request`` method, or a + method-only interface providing ``get``. Returns: list[dict]: Rulesets as returned by the GitHub API. Each item contains at minimum ``id``, ``name``, and ``enforcement``. @@ -137,8 +137,8 @@ def copy_ruleset( target_repo (str): Target repository in ``owner/repo`` format. ruleset_name (str): Name of the ruleset to copy. token (str, optional): GitHub token with ``repo`` scope. - session: Object with a requests-compatible ``request`` method. - + session: Object with a requests-compatible ``request`` method, or a + method-only interface providing ``get``/``post``. Returns: dict: The created ruleset as returned by the GitHub API. From dbc05ad6fb80060bd4f0c0f186468801d28068d5 Mon Sep 17 00:00:00 2001 From: "Kelly Sovacool, PhD" Date: Thu, 25 Jun 2026 15:12:23 -0400 Subject: [PATCH 09/14] style: fix python returns Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/ccbr_actions/__main__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/ccbr_actions/__main__.py b/src/ccbr_actions/__main__.py index 77703b4..3519cdb 100644 --- a/src/ccbr_actions/__main__.py +++ b/src/ccbr_actions/__main__.py @@ -85,9 +85,9 @@ def list_rulesets_cmd(repo, token): rulesets = list_rulesets(repo=repo, token=token) if not rulesets: click.echo(f"No rulesets found in {repo}.") - return - for r in rulesets: - click.echo(f"{r['id']:>10} {r['enforcement']:<12} {r['name']}") + else: + for r in rulesets: + click.echo(f"{r['id']:>10} {r['enforcement']:<12} {r['name']}") cli.add_command(list_rulesets_cmd, name="list-rulesets") From cc9ca1f6d710866d865e5ed19765efdb57f0ad63 Mon Sep 17 00:00:00 2001 From: Kelly Sovacool Date: Thu, 25 Jun 2026 15:29:14 -0400 Subject: [PATCH 10/14] refactor: use gh api directly --- copy-ruleset/README.qmd | 43 ++++++++++++++++++++++++------------ copy-ruleset/action.yml | 49 ++++++++++++++++++----------------------- 2 files changed, 50 insertions(+), 42 deletions(-) diff --git a/copy-ruleset/README.qmd b/copy-ruleset/README.qmd index a54532e..97867f9 100644 --- a/copy-ruleset/README.qmd +++ b/copy-ruleset/README.qmd @@ -17,9 +17,6 @@ Copy a named ruleset from one GitHub repository to another. This action fetches the full ruleset definition from the source repository via the GitHub API and creates an identical ruleset in the target repository. It is useful for standardising branch protection rules across multiple repositories in an organisation. -> **Tip:** Not sure what rulesets exist in a repository? -> Run `ccbr_actions list-rulesets owner/repo` locally to see all available ruleset names and their enforcement status. - ## Usage ### Basic example @@ -49,18 +46,36 @@ steps: token: ${{ secrets.ORG_PAT }} ``` -### Pinning versions +## Running locally -```yaml -steps: - - uses: CCBR/actions/copy-ruleset@main - with: - source-repo: CCBR/actions - target-repo: CCBR/other-repo - ruleset-name: "Require PR reviews" - token: ${{ secrets.ORG_PAT }} - python-version: "3.12" - ccbr-actions-version: "v1.0.0" +Both operations can also be performed directly in the terminal using the [`gh` CLI](https://cli.github.com/) and `jq`, without installing any additional packages. + +**List rulesets** in a repository: + +```bash +gh api repos/{owner}/{repo}/rulesets --jq '.[] | [.id, .enforcement, .name] | @tsv' +``` + +**Copy a ruleset** from one repository to another: + +```bash +gh api repos/{source_owner}/{source_repo}/rulesets \ + --jq '.[] | select(.name == "Require PR reviews") | .id' \ + | xargs -I{} gh api repos/{source_owner}/{source_repo}/rulesets/{} \ + | jq 'del(.id, .node_id, .created_at, .updated_at, ._links)' \ + | gh api repos/{target_owner}/{target_repo}/rulesets \ + --method POST \ + --input - +``` + +Alternatively, using the `ccbr_actions` Python package: + +```bash +# list rulesets +ccbr_actions list-rulesets owner/repo + +# copy a ruleset +ccbr_actions copy-ruleset CCBR/actions CCBR/other-repo "Require PR reviews" ``` ```{python} diff --git a/copy-ruleset/action.yml b/copy-ruleset/action.yml index ed0510f..1a2e900 100644 --- a/copy-ruleset/action.yml +++ b/copy-ruleset/action.yml @@ -22,41 +22,34 @@ inputs: description: "GitHub token with repo scope on both repositories." required: false default: "${{ github.token }}" - python-version: - description: "The version of Python to install." - required: false - default: "3.11" - ccbr-actions-version: - description: "The version of ccbr_actions to use." - required: false - default: "main" runs: using: composite steps: - - uses: actions/setup-python@v6 - with: - python-version: "${{ inputs.python-version }}" - - - name: Install ccbr_actions - shell: bash - run: python -m pip install --upgrade pip "git+https://github.com/CCBR/actions.git@${{ inputs.ccbr-actions-version }}" - - name: Copy ruleset - shell: python + shell: bash env: GH_TOKEN: "${{ inputs.token }}" - SOURCE_REPO: "${{ inputs.source-repo }}" - TARGET_REPO: "${{ inputs.target-repo }}" - RULESET_NAME: "${{ inputs.ruleset-name }}" run: | - import os - from ccbr_actions.github import copy_ruleset + ruleset_id=$( + gh api "repos/${{ inputs.source-repo }}/rulesets" \ + --jq --arg name "${{ inputs.ruleset-name }}" \ + '.[] | select(.name == $name) | .id' + ) - result = copy_ruleset( - source_repo=os.environ["SOURCE_REPO"], - target_repo=os.environ["TARGET_REPO"], - ruleset_name=os.environ["RULESET_NAME"], - token=os.environ.get("GH_TOKEN", ""), + if [ -z "$ruleset_id" ]; then + echo "::error::Ruleset '${{ inputs.ruleset-name }}' not found in ${{ inputs.source-repo }}." + echo "Available rulesets:" + gh api "repos/${{ inputs.source-repo }}/rulesets" --jq '.[].name' + exit 1 + fi + + result=$( + gh api "repos/${{ inputs.source-repo }}/rulesets/${ruleset_id}" \ + | jq 'del(.id, .node_id, .created_at, .updated_at, ._links)' \ + | gh api "repos/${{ inputs.target-repo }}/rulesets" \ + --method POST \ + --input - ) - print(f"Ruleset '{result['name']}' (id={result['id']}) created in {os.environ['TARGET_REPO']}.") + + echo "Ruleset '$(echo "$result" | jq -r '.name')' (id=$(echo "$result" | jq -r '.id')) created in ${{ inputs.target-repo }}." From a5344f5e7f0659d6dd1ab6a0df3c58447718ad5e Mon Sep 17 00:00:00 2001 From: CCBR-bot <258092125+ccbr-bot@users.noreply.github.com> Date: Thu, 25 Jun 2026 19:35:29 +0000 Subject: [PATCH 11/14] =?UTF-8?q?ci:=20=F0=9F=A4=96=20render=20readme=20&?= =?UTF-8?q?=20format=20everything=20with=20pre-commit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- copy-ruleset/README.md | 49 ++++++++++++++++++++++++++---------------- 1 file changed, 31 insertions(+), 18 deletions(-) diff --git a/copy-ruleset/README.md b/copy-ruleset/README.md index 5f5f728..92a2125 100644 --- a/copy-ruleset/README.md +++ b/copy-ruleset/README.md @@ -9,10 +9,6 @@ GitHub API and creates an identical ruleset in the target repository. It is useful for standardising branch protection rules across multiple repositories in an organisation. -> **Tip:** Not sure what rulesets exist in a repository? Run -> `ccbr_actions list-rulesets owner/repo` locally to see all available -> ruleset names and their enforcement status. - ## Usage ### Basic example @@ -67,18 +63,38 @@ steps: token: ${{ secrets.ORG_PAT }} ``` -### Pinning versions +## Running locally -```yaml -steps: - - uses: CCBR/actions/copy-ruleset@main - with: - source-repo: CCBR/actions - target-repo: CCBR/other-repo - ruleset-name: "Require PR reviews" - token: ${{ secrets.ORG_PAT }} - python-version: "3.12" - ccbr-actions-version: "v1.0.0" +Both operations can also be performed directly in the terminal using the +[`gh` CLI](https://cli.github.com/) and `jq`, without installing any +additional packages. + +**List rulesets** in a repository: + +```bash +gh api repos/{owner}/{repo}/rulesets --jq '.[] | [.id, .enforcement, .name] | @tsv' +``` + +**Copy a ruleset** from one repository to another: + +```bash +gh api repos/{source_owner}/{source_repo}/rulesets \ + --jq '.[] | select(.name == "Require PR reviews") | .id' \ + | xargs -I{} gh api repos/{source_owner}/{source_repo}/rulesets/{} \ + | jq 'del(.id, .node_id, .created_at, .updated_at, ._links)' \ + | gh api repos/{target_owner}/{target_repo}/rulesets \ + --method POST \ + --input - +``` + +Alternatively, using the `ccbr_actions` Python package: + +```bash +# list rulesets +ccbr_actions list-rulesets owner/repo + +# copy a ruleset +ccbr_actions copy-ruleset CCBR/actions CCBR/other-repo "Require PR reviews" ``` ## Inputs @@ -88,6 +104,3 @@ steps: - `ruleset-name`: Name of the ruleset to copy. **Required.** - `token`: GitHub token with repo scope on both repositories. Default: `${{ github.token }}`. -- `python-version`: The version of Python to install. Default: `3.11`. -- `ccbr-actions-version`: The version of ccbr_actions to use. Default: - `main`. From a5650094aedc658afcf82ac9392b376d8da65755 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 26 Jun 2026 14:03:50 +0000 Subject: [PATCH 12/14] docs(copy-ruleset): add automatic workflow trigger examples --- copy-ruleset/README.md | 18 +++++++++++++++--- examples/copy-ruleset.yml | 19 ++++++++++++++++--- 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/copy-ruleset/README.md b/copy-ruleset/README.md index 92a2125..acf438a 100644 --- a/copy-ruleset/README.md +++ b/copy-ruleset/README.md @@ -30,6 +30,15 @@ on: ruleset-name: description: "Name of the ruleset to copy." required: true + push: + branches: + - main + - master + repository_dispatch: + types: + - copy-ruleset + schedule: + - cron: "0 5 * * 1" permissions: contents: read @@ -40,12 +49,15 @@ jobs: steps: - uses: CCBR/actions/copy-ruleset@latest with: - source-repo: ${{ inputs.source-repo }} - target-repo: ${{ inputs.target-repo }} - ruleset-name: ${{ inputs.ruleset-name }} + source-repo: ${{ inputs['source-repo'] || vars.RULESET_SOURCE_REPO }} + target-repo: ${{ inputs['target-repo'] || vars.RULESET_TARGET_REPO || github.repository }} + ruleset-name: ${{ inputs['ruleset-name'] || vars.RULESET_NAME }} token: ${{ secrets.ORG_PAT }} ``` +For non-manual triggers (`push`, `repository_dispatch`, `schedule`), set repository +variables (`RULESET_SOURCE_REPO`, `RULESET_TARGET_REPO`, `RULESET_NAME`). + ### Using a PAT for cross-repository access `github.token` is scoped to the repository running the workflow. To copy diff --git a/examples/copy-ruleset.yml b/examples/copy-ruleset.yml index a25b6b4..7f00c79 100644 --- a/examples/copy-ruleset.yml +++ b/examples/copy-ruleset.yml @@ -12,6 +12,19 @@ on: ruleset-name: description: "Name of the ruleset to copy." required: true + # Push to the default branch can handle first runs in repositories + # created from templates (initial commit push). + push: + branches: + - main + - master + # Allow central automation to trigger this workflow remotely. + repository_dispatch: + types: + - copy-ruleset + # Optional periodic sync to keep rulesets aligned over time. + schedule: + - cron: "0 5 * * 1" permissions: contents: read @@ -22,7 +35,7 @@ jobs: steps: - uses: CCBR/actions/copy-ruleset@latest with: - source-repo: ${{ inputs.source-repo }} - target-repo: ${{ inputs.target-repo }} - ruleset-name: ${{ inputs.ruleset-name }} + source-repo: ${{ inputs['source-repo'] || vars.RULESET_SOURCE_REPO }} + target-repo: ${{ inputs['target-repo'] || vars.RULESET_TARGET_REPO || github.repository }} + ruleset-name: ${{ inputs['ruleset-name'] || vars.RULESET_NAME }} token: ${{ secrets.ORG_PAT }} From 602467bcda4c70657f967a308ed76982754f3989 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 26 Jun 2026 14:05:57 +0000 Subject: [PATCH 13/14] docs(copy-ruleset): guard automated trigger inputs --- copy-ruleset/README.md | 14 ++++++++++---- examples/copy-ruleset.yml | 11 ++++++++--- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/copy-ruleset/README.md b/copy-ruleset/README.md index acf438a..004623e 100644 --- a/copy-ruleset/README.md +++ b/copy-ruleset/README.md @@ -45,18 +45,24 @@ permissions: jobs: copy-ruleset: + if: >- + ${{ + github.event_name == 'workflow_dispatch' || + (vars.RULESET_SOURCE_REPO != '' && vars.RULESET_NAME != '') + }} runs-on: ubuntu-latest steps: - uses: CCBR/actions/copy-ruleset@latest with: - source-repo: ${{ inputs['source-repo'] || vars.RULESET_SOURCE_REPO }} - target-repo: ${{ inputs['target-repo'] || vars.RULESET_TARGET_REPO || github.repository }} - ruleset-name: ${{ inputs['ruleset-name'] || vars.RULESET_NAME }} + source-repo: ${{ github.event_name == 'workflow_dispatch' && inputs['source-repo'] || vars.RULESET_SOURCE_REPO }} + target-repo: ${{ github.event_name == 'workflow_dispatch' && inputs['target-repo'] || vars.RULESET_TARGET_REPO || github.repository }} + ruleset-name: ${{ github.event_name == 'workflow_dispatch' && inputs['ruleset-name'] || vars.RULESET_NAME }} token: ${{ secrets.ORG_PAT }} ``` For non-manual triggers (`push`, `repository_dispatch`, `schedule`), set repository -variables (`RULESET_SOURCE_REPO`, `RULESET_TARGET_REPO`, `RULESET_NAME`). +variables (`RULESET_SOURCE_REPO`, `RULESET_NAME`, and optionally +`RULESET_TARGET_REPO`; defaults to the current repository when unset). ### Using a PAT for cross-repository access diff --git a/examples/copy-ruleset.yml b/examples/copy-ruleset.yml index 7f00c79..81cb1f7 100644 --- a/examples/copy-ruleset.yml +++ b/examples/copy-ruleset.yml @@ -31,11 +31,16 @@ permissions: jobs: copy-ruleset: + if: >- + ${{ + github.event_name == 'workflow_dispatch' || + (vars.RULESET_SOURCE_REPO != '' && vars.RULESET_NAME != '') + }} runs-on: ubuntu-latest steps: - uses: CCBR/actions/copy-ruleset@latest with: - source-repo: ${{ inputs['source-repo'] || vars.RULESET_SOURCE_REPO }} - target-repo: ${{ inputs['target-repo'] || vars.RULESET_TARGET_REPO || github.repository }} - ruleset-name: ${{ inputs['ruleset-name'] || vars.RULESET_NAME }} + source-repo: ${{ github.event_name == 'workflow_dispatch' && inputs['source-repo'] || vars.RULESET_SOURCE_REPO }} + target-repo: ${{ github.event_name == 'workflow_dispatch' && inputs['target-repo'] || vars.RULESET_TARGET_REPO || github.repository }} + ruleset-name: ${{ github.event_name == 'workflow_dispatch' && inputs['ruleset-name'] || vars.RULESET_NAME }} token: ${{ secrets.ORG_PAT }} From 8ecec8c283879f81c9338297d2ea08ba7d1eb30b Mon Sep 17 00:00:00 2001 From: CCBR-bot <258092125+ccbr-bot@users.noreply.github.com> Date: Mon, 29 Jun 2026 15:43:20 +0000 Subject: [PATCH 14/14] =?UTF-8?q?ci:=20=F0=9F=A4=96=20render=20readme=20&?= =?UTF-8?q?=20format=20everything=20with=20pre-commit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- copy-ruleset/README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/copy-ruleset/README.md b/copy-ruleset/README.md index 004623e..f2c7b4d 100644 --- a/copy-ruleset/README.md +++ b/copy-ruleset/README.md @@ -30,13 +30,17 @@ on: ruleset-name: description: "Name of the ruleset to copy." required: true + # Push to the default branch can handle first runs in repositories + # created from templates (initial commit push). push: branches: - main - master + # Allow central automation to trigger this workflow remotely. repository_dispatch: types: - copy-ruleset + # Optional periodic sync to keep rulesets aligned over time. schedule: - cron: "0 5 * * 1" @@ -60,10 +64,6 @@ jobs: token: ${{ secrets.ORG_PAT }} ``` -For non-manual triggers (`push`, `repository_dispatch`, `schedule`), set repository -variables (`RULESET_SOURCE_REPO`, `RULESET_NAME`, and optionally -`RULESET_TARGET_REPO`; defaults to the current repository when unset). - ### Using a PAT for cross-repository access `github.token` is scoped to the repository running the workflow. To copy