diff --git a/github_rest_api/github.py b/github_rest_api/github.py index a33d139..1a56a00 100644 --- a/github_rest_api/github.py +++ b/github_rest_api/github.py @@ -1,16 +1,45 @@ """Simple wrapper of GitHub REST APIs.""" +import re from abc import ABCMeta, abstractmethod from base64 import b64encode from collections.abc import Sequence from enum import StrEnum -from typing import Any, Callable from pathlib import Path +from typing import Any, Callable + import requests from nacl import encoding, public URL_API = "https://api.github.com" +_SECRET_NAME_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") + + +def _validate_secret_name(name: str) -> None: + """Validate a secret name against GitHub's naming rules. + + GitHub rejects invalid secret names with a 422 response. Validating the + name client-side surfaces a clear error before the request is sent. + + :param name: The name of the secret. + :raises ValueError: If the name is empty, starts with the reserved + ``GITHUB_`` prefix, starts with a digit, or contains characters other + than alphanumerics and underscores. + """ + if not name: + raise ValueError("A secret name must not be empty.") + if name.upper().startswith("GITHUB_"): + raise ValueError( + f"Invalid secret name {name!r}: names must not start with the " + "reserved 'GITHUB_' prefix." + ) + if not _SECRET_NAME_PATTERN.fullmatch(name): + raise ValueError( + f"Invalid secret name {name!r}: names may only contain alphanumeric " + "characters and underscores, and must not start with a digit." + ) + def _encrypt_secret(public_key: str, value: str) -> str: """Encrypt a secret value using a LibSodium sealed box. @@ -354,6 +383,7 @@ def create_or_update_secret( automatically. Fetch it once and reuse it to avoid a redundant request when creating or updating multiple secrets. """ + _validate_secret_name(name) if public_key is None: public_key = self.get_secret_public_key() return self._put( @@ -549,6 +579,7 @@ def create_or_update_secret( :param selected_repository_ids: Repository IDs that can access the secret when visibility is `selected`. """ + _validate_secret_name(name) if selected_repository_ids and visibility != SecretVisibility.SELECTED: raise ValueError( "`selected_repository_ids` can only be provided when `visibility` is 'selected'." diff --git a/github_rest_api/scripts/cargo/benchmark.py b/github_rest_api/scripts/cargo/benchmark.py index b884405..19ea24b 100644 --- a/github_rest_api/scripts/cargo/benchmark.py +++ b/github_rest_api/scripts/cargo/benchmark.py @@ -1,18 +1,20 @@ """Benchmark action using cargo criterion.""" -from typing import Callable -import tempfile -from pathlib import Path import datetime import shutil import subprocess as sp +import tempfile +from pathlib import Path +from typing import Callable + from dulwich import porcelain + from ..utils import ( + commit_benchmarks, config_git, - switch_branch, - push_branch, gen_temp_branch, - commit_benchmarks, + push_branch, + switch_branch, ) diff --git a/github_rest_api/scripts/cargo/profiling.py b/github_rest_api/scripts/cargo/profiling.py index b55094c..94cf6e0 100644 --- a/github_rest_api/scripts/cargo/profiling.py +++ b/github_rest_api/scripts/cargo/profiling.py @@ -1,14 +1,16 @@ """Utils for profiling Rust applications.""" -from typing import Iterable -from pathlib import Path -import time import datetime import subprocess as sp +import time +from pathlib import Path +from typing import Iterable + import psutil -from .utils import build_project -from ..utils import config_git, switch_branch, push_branch, commit_profiling + from ...utils import partition +from ..utils import commit_profiling, config_git, push_branch, switch_branch +from .utils import build_project def launch_application(cmd: list[str]) -> int: diff --git a/github_rest_api/scripts/container/build_container_images.py b/github_rest_api/scripts/container/build_container_images.py index ae577a5..a57e176 100644 --- a/github_rest_api/scripts/container/build_container_images.py +++ b/github_rest_api/scripts/container/build_container_images.py @@ -1,16 +1,17 @@ import argparse -from collections.abc import Sequence import datetime -from pathlib import Path import subprocess as sp import sys +from collections.abc import Sequence +from pathlib import Path from typing import cast + import yaml -from dulwich.repo import Repo -from dulwich.refs import Ref -from dulwich.objects import Commit -from dulwich.errors import NotGitRepository from dulwich.diff_tree import tree_changes +from dulwich.errors import NotGitRepository +from dulwich.objects import Commit +from dulwich.refs import Ref +from dulwich.repo import Repo from tenacity import retry, stop_after_attempt, wait_exponential diff --git a/github_rest_api/scripts/container/config_container.py b/github_rest_api/scripts/container/config_container.py index 6c4829c..dfd4a17 100644 --- a/github_rest_api/scripts/container/config_container.py +++ b/github_rest_api/scripts/container/config_container.py @@ -1,11 +1,12 @@ import argparse import json import shutil +import subprocess as sp import sys import tomllib -import tomli_w from pathlib import Path -import subprocess as sp + +import tomli_w def config_docker(data_root: str = "/mnt/docker"): diff --git a/github_rest_api/scripts/container/update_version_containerfile.py b/github_rest_api/scripts/container/update_version_containerfile.py index 94418f1..1f50faa 100644 --- a/github_rest_api/scripts/container/update_version_containerfile.py +++ b/github_rest_api/scripts/container/update_version_containerfile.py @@ -1,13 +1,15 @@ import argparse import datetime import os +import re import sys from pathlib import Path -import re + from dulwich import porcelain +from requests.exceptions import HTTPError + from github_rest_api import Repository from github_rest_api.utils import next_minor_or_strip_patch -from requests.exceptions import HTTPError def parse_latest_version(repo: str) -> str: diff --git a/github_rest_api/scripts/github/add_github_repo.py b/github_rest_api/scripts/github/add_github_repo.py index 02ce0fa..aedb374 100644 --- a/github_rest_api/scripts/github/add_github_repo.py +++ b/github_rest_api/scripts/github/add_github_repo.py @@ -7,9 +7,10 @@ import sys from collections.abc import Sequence from pathlib import Path + from dulwich import porcelain -from github_rest_api import User, Organization +from github_rest_api import Organization, User def _validate_repo(repo: str) -> None: diff --git a/github_rest_api/scripts/github/create_pull_request.py b/github_rest_api/scripts/github/create_pull_request.py index 72ddf20..2e562d7 100644 --- a/github_rest_api/scripts/github/create_pull_request.py +++ b/github_rest_api/scripts/github/create_pull_request.py @@ -2,9 +2,10 @@ The branch is updated (using dev) before creating the PR. """ -from argparse import ArgumentParser, Namespace import os import sys +from argparse import ArgumentParser, Namespace + from github_rest_api import Repository from github_rest_api.utils import compile_patterns diff --git a/github_rest_api/scripts/github/release_on_github.py b/github_rest_api/scripts/github/release_on_github.py index d16883f..b9a4783 100644 --- a/github_rest_api/scripts/github/release_on_github.py +++ b/github_rest_api/scripts/github/release_on_github.py @@ -1,9 +1,10 @@ +import argparse +import getpass import os import re import sys -import argparse -import getpass from pathlib import Path + from github_rest_api import Repository from github_rest_api.scripts.utils import ( find_project_root, diff --git a/github_rest_api/scripts/github/remove_branch.py b/github_rest_api/scripts/github/remove_branch.py index 1885c69..9fd1a56 100644 --- a/github_rest_api/scripts/github/remove_branch.py +++ b/github_rest_api/scripts/github/remove_branch.py @@ -1,7 +1,8 @@ import argparse +import datetime import re import sys -import datetime + from github_rest_api import Repository diff --git a/github_rest_api/scripts/utils.py b/github_rest_api/scripts/utils.py index ebbf68a..b0448fe 100644 --- a/github_rest_api/scripts/utils.py +++ b/github_rest_api/scripts/utils.py @@ -1,10 +1,11 @@ """Util functions for GitHub actions.""" +import random import tomllib -from typing import Any, Iterable -from pathlib import Path from collections.abc import Sequence -import random +from pathlib import Path +from typing import Any, Iterable + from dulwich import porcelain from dulwich.repo import Repo diff --git a/github_rest_api/utils.py b/github_rest_api/utils.py index 6bd4c27..a07f3f2 100644 --- a/github_rest_api/utils.py +++ b/github_rest_api/utils.py @@ -1,8 +1,8 @@ """Some generally useful util functions.""" -from collections.abc import Sequence -from itertools import tee, filterfalse import re +from collections.abc import Sequence +from itertools import filterfalse, tee def partition(pred, iterable): diff --git a/pyproject.toml b/pyproject.toml index 33e062d..c8255b2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ requires = [ "hatchling" ] [project] name = "github-rest-api" -version = "0.42.0" +version = "0.42.1" description = "Simple wrapper of GitHub REST APIs." readme = "README.md" authors = [ { name = "Ben Du", email = "longendu@yahoo.com" } ] @@ -41,3 +41,8 @@ dev = [ "ruff>=0.14.10", "ty>=0.0.8", ] + +[tool.ruff.lint] +# select the 'I' rule set for import sorting +extend-select = ["I"] + diff --git a/tests/test_build_container_images.py b/tests/test_build_container_images.py index f1df51d..5072886 100644 --- a/tests/test_build_container_images.py +++ b/tests/test_build_container_images.py @@ -1,5 +1,6 @@ from pathlib import Path from unittest.mock import patch + from github_rest_api.scripts.container.build_container_images import ( has_relevant_changes, ) diff --git a/tests/test_github.py b/tests/test_github.py index da6a350..acc69ff 100644 --- a/tests/test_github.py +++ b/tests/test_github.py @@ -1,7 +1,16 @@ import os from base64 import b64decode + +import pytest from nacl import encoding, public -from github_rest_api.github import User, Organization, Repository, _encrypt_secret + +from github_rest_api.github import ( + Organization, + Repository, + User, + _encrypt_secret, + _validate_secret_name, +) TOKEN = os.environ.get("GITHUB_TOKEN", "") @@ -14,6 +23,32 @@ def test_encrypt_secret_roundtrip(): assert decrypted == b"s3cret-value" +@pytest.mark.parametrize( + "name", + ["MY_SECRET", "_underscore", "Token123", "a"], +) +def test_validate_secret_name_valid(name): + _validate_secret_name(name) + + +@pytest.mark.parametrize( + "name", + [ + "", + "GITHUB_ACTIONS", + "GITHUB_TOKEN", + "github_token", + "GitHub_Token", + "1SECRET", + "MY-SECRET", + "MY SECRET", + ], +) +def test_validate_secret_name_invalid(name): + with pytest.raises(ValueError): + _validate_secret_name(name) + + def test_user_get_repositories(): user = User(TOKEN, "dclong") repos = user.get_repositories() diff --git a/tests/test_release_on_github.py b/tests/test_release_on_github.py index ec3edb1..660e9fe 100644 --- a/tests/test_release_on_github.py +++ b/tests/test_release_on_github.py @@ -1,6 +1,8 @@ from pathlib import Path from unittest.mock import patch + import pytest + from github_rest_api.scripts.github.release_on_github import _get_release_tag ROOT = Path(".") diff --git a/tests/test_utils.py b/tests/test_utils.py index 3db890e..f9e249d 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,4 +1,5 @@ import pytest + from github_rest_api.utils import next_minor_or_strip_patch, strip_patch_version diff --git a/uv.lock b/uv.lock index 3bd5cc2..007e4df 100644 --- a/uv.lock +++ b/uv.lock @@ -223,7 +223,7 @@ wheels = [ [[package]] name = "github-rest-api" -version = "0.41.0" +version = "0.42.1" source = { editable = "." } dependencies = [ { name = "dulwich" },