From 4d97af74a089b381e79a0a0dd7ffbc4151e31cd0 Mon Sep 17 00:00:00 2001 From: Francesco Nazzaro Date: Tue, 27 Oct 2026 09:21:17 +0100 Subject: [PATCH 1/9] initialize uv prject --- .python-version | 1 + copy-git-clone-script/action.yml | 4 +- git-clone/action.yml | 2 +- pyproject.toml | 21 +++ src/ci_cd/__init__.py | 0 src/ci_cd/get_kubeconfig.py | 152 ++++++++++++++++++ .../ci_cd/git_clone_ref.py | 3 +- uv.lock | 136 ++++++++++++++++ 8 files changed, 315 insertions(+), 4 deletions(-) create mode 100644 .python-version create mode 100644 pyproject.toml create mode 100644 src/ci_cd/__init__.py create mode 100644 src/ci_cd/get_kubeconfig.py rename scripts/git-clone-ref.py => src/ci_cd/git_clone_ref.py (99%) create mode 100644 uv.lock diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..6324d40 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.14 diff --git a/copy-git-clone-script/action.yml b/copy-git-clone-script/action.yml index 2085091..b45074e 100644 --- a/copy-git-clone-script/action.yml +++ b/copy-git-clone-script/action.yml @@ -2,7 +2,7 @@ name: 'Copy Git Clone Script' description: 'It copies the git clone script' inputs: target-path: - description: 'The path where the script should be copied' + description: 'The directory where git-clone-ref.py should be copied' required: true runs: using: "composite" @@ -12,4 +12,4 @@ runs: TARGET_PATH: ${{ inputs.target-path }} shell: bash run: | - cp ${{ github.action_path }}/../scripts/git-clone-ref.py ${TARGET_PATH} + cp ${{ github.action_path }}/../src/ci_cd/git_clone_ref.py ${TARGET_PATH}/git-clone-ref.py diff --git a/git-clone/action.yml b/git-clone/action.yml index a1a5134..55ce569 100644 --- a/git-clone/action.yml +++ b/git-clone/action.yml @@ -29,4 +29,4 @@ runs: DEFAULT_REPO_REF: ${{ inputs.default-repo-ref }} PYPROJECT_PATH: ${{ inputs.pyproject-path }} run: | - uv run "$GITHUB_ACTION_PATH/../scripts/git-clone-ref.py" ${REPO_LIST} --default-repo-ref "${DEFAULT_REPO_REF}" --pyproject-path "${PYPROJECT_PATH}" + uv run --project "$GITHUB_ACTION_PATH/.." git-clone-ref ${REPO_LIST} --default-repo-ref "${DEFAULT_REPO_REF}" --pyproject-path "${PYPROJECT_PATH}" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..c22ee8b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,21 @@ +[project] +name = "ci-cd" +version = "0.1.0" +description = "Reusable GitHub Actions and the scripts backing them" +readme = "README.md" +authors = [ + { name = "Francesco Nazzaro", email = "francesconazzaro89@gmail.com" } +] +requires-python = ">=3.12" +dependencies = [ + "gitpython", + "typer", +] + +[project.scripts] +git-clone-ref = "ci_cd.git_clone_ref:app" +get-kubeconfig = "ci_cd.get_kubeconfig:app" + +[build-system] +requires = ["uv_build>=0.12.7,<0.13.0"] +build-backend = "uv_build" diff --git a/src/ci_cd/__init__.py b/src/ci_cd/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/ci_cd/get_kubeconfig.py b/src/ci_cd/get_kubeconfig.py new file mode 100644 index 0000000..dd5502c --- /dev/null +++ b/src/ci_cd/get_kubeconfig.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python +# /// script +# requires-python = ">=3.12" +# dependencies = [] +# /// + + +import json +import logging +import pathlib +import subprocess +from typing import Annotated + +import typer + +logger = logging.getLogger(__name__) + +app = typer.Typer() + + +def run_pass_cli(*args: str) -> dict: + result = subprocess.run( + ["pass-cli", *args, "--output", "json"], + capture_output=True, + text=True, + check=True, + ) + return json.loads(result.stdout) + + +def get_vault_id(name) -> str: + """ + Get the vault ID for a given name using the `pass-cli` command-line tool. + + Args: + name (str): The name of the vault to retrieve the ID for. + """ + vaults = run_pass_cli("vault", "list")["vaults"] + for vault in vaults: + if vault["name"] == name: + logger.info(f"Found vault {name!r} with ID {vault['share_id']!r}") + return vault["share_id"] + raise ValueError(f"vault {name!r} not found") + + +def get_item_id(vault_id: str, item_name: str) -> str: + """ + Get the item ID for a given vault ID and item name using the `pass-cli` command-line tool. + + Args: + vault_id (str): The ID of the vault containing the item. + item_name (str): The name of the item to retrieve the ID for. + """ + items = run_pass_cli("item", "list", f"--share-id={vault_id}")["items"] + for item in items: + if item["title"] == item_name: + logger.info(f"Found item {item_name!r} with ID {item['id']!r} in vault {vault_id!r}") + return item["id"] + raise ValueError(f"item {item_name!r} not found in vault {vault_id!r}") + + +def get_attachment_id( + vault_id: str, item_id: str, attachment_name: str = "kubeconfig.yaml" +) -> str: + """ + Get the attachment ID for a given vault ID, item ID, and attachment name using the `pass-cli` command-line tool. + + Args: + vault_id (str): The ID of the vault containing the item. + item_id (str): The ID of the item containing the attachment. + attachment_name (str): The name of the attachment to retrieve the ID for. + """ + attachments = run_pass_cli( + "item", "view", f"--share-id={vault_id}", f"--item-id={item_id}" + )["attachments"] + for attachment in attachments: + if attachment["content"]["name"] == attachment_name: + logger.info(f"Found attachment {attachment_name!r} with ID {attachment['id']!r} in item {item_id!r} of vault {vault_id!r}") + return attachment["id"] + raise ValueError( + f"attachment {attachment_name!r} not found in item {item_id!r} of vault {vault_id!r}" + ) + + +def download_kubeconfig( + vault_id: str, item_id: str, attachment_id: str, output_path: pathlib.Path +) -> str: + """ + Download a kubeconfig file from a vault and save it to the specified output path. + + Args: + vault_id (str): The ID of the vault containing the kubeconfig item. + item_id (str): The ID of the kubeconfig item to download. + attachment_id (str): The ID of the attachment to download. + output_path (pathlib.Path): The path where the kubeconfig file will be saved. + """ + logger.info(f"Downloading kubeconfig from vault {vault_id!r}, item {item_id!r}, attachment {attachment_id!r}") + output_path.parent.mkdir(parents=True, exist_ok=True) + subprocess.run( + [ + "pass-cli", + "item", + "attachment", + "download", + f"--share-id={vault_id}", + f"--item-id={item_id}", + f"--attachment-id={attachment_id}", + f"--output={output_path}", + ], + check=True, + ) + return str(output_path) + + +@app.command() +def main( + vault: Annotated[ + str, + typer.Option( + "--vault", + "-v", + envvar="CICD_VAULT_NAME", + help="Name of the vault containing the kubeconfig.", + ), + ], + environment: Annotated[ + str, + typer.Option( + "--environment", + "-e", + envvar="CICD_ENVIRONMENT", + help="Name of the kubernetes environment to download.", + ), + ], + output_path: Annotated[ + pathlib.Path | None, + typer.Option( + "--output-path", "-o", envvar="CICD_OUTPUT_PATH", help="Path where the kubeconfig file will be saved." + ), + ] = None, +): + logging.basicConfig(level=logging.INFO) + vault_id = get_vault_id(vault) + item_id = get_item_id(vault_id, environment) + attachment_id = get_attachment_id(vault_id, item_id) + if output_path is None: + output_path = pathlib.Path(f"local.{environment.replace(' ', '_')}.kubeconfig.yaml") + download_kubeconfig(vault_id, item_id, attachment_id, output_path) + + +if __name__ == "__main__": + app() diff --git a/scripts/git-clone-ref.py b/src/ci_cd/git_clone_ref.py similarity index 99% rename from scripts/git-clone-ref.py rename to src/ci_cd/git_clone_ref.py index f3c692f..021b2b5 100755 --- a/scripts/git-clone-ref.py +++ b/src/ci_cd/git_clone_ref.py @@ -1,5 +1,6 @@ #!/usr/bin/env python # /// script +# requires-python = ">=3.12" # dependencies = [ # "gitpython", # "typer", @@ -10,10 +11,10 @@ import os import shutil import tempfile +import tomllib from typing import Annotated import git -import tomllib import typer logger = logging.getLogger(__name__) diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..fbbace6 --- /dev/null +++ b/uv.lock @@ -0,0 +1,136 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "annotated-doc" +version = "0.0.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" }, +] + +[[package]] +name = "ci-cd" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "gitpython" }, + { name = "typer" }, +] + +[package.metadata] +requires-dist = [ + { name = "gitpython" }, + { name = "typer" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "gitdb" +version = "4.0.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "smmap" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571", size = 394684, upload-time = "2025-01-02T07:20:46.413Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf", size = 62794, upload-time = "2025-01-02T07:20:43.624Z" }, +] + +[[package]] +name = "gitpython" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "gitdb" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/ff/fee293f1ec3e1b64db6eb270e00964f29c086d769201474cedb8d1608930/gitpython-3.2.1.tar.gz", hash = "sha256:0f1229ec5c057bdabdeeda8bda4f17bda679ab33dbccc00f24e6a480553c8613", size = 241688, upload-time = "2026-10-14T12:20:20.589Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/50/2a923c4dc02522ff9567f07c67ed1deca6d5fa9474f4708168b8088dfa37/gitpython-3.2.1-py3-none-any.whl", hash = "sha256:d66b0832aa8755c1a84892937282df0befb16c97e5a64aeebd68be9c5e323f53", size = 231548, upload-time = "2026-10-14T12:20:18.844Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "smmap" +version = "5.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/ea/49c993d6dfdd7338c9b1000a0f36817ed7ec84577ae2e52f890d1a4ff909/smmap-5.0.3.tar.gz", hash = "sha256:4d9debb8b99007ae47165abc08670bd74cb74b5227dda7f643eccc4e9eb5642c", size = 22506, upload-time = "2026-03-09T03:43:26.1Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl", hash = "sha256:c106e05d5a61449cf6ba9a1e650227ecfb141590d2a98412103ff35d89fc7b2f", size = 24390, upload-time = "2026-03-09T03:43:24.361Z" }, +] + +[[package]] +name = "typer" +version = "0.27.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/03/51/d33db42cc72ffd8c30777547b42d01f0cbf9d95a770457698d0174b3ed71/typer-0.27.3.tar.gz", hash = "sha256:d0396f770a560ab1b0a8504e13b5f254b728cedb05c61cf0359e944e50ce8901", size = 205303, upload-time = "2026-10-06T17:24:16.61Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/ea/2e31b67051e91a133189e9c000c222502ddc6969856416de0d095de4c0b0/typer-0.27.3-py3-none-any.whl", hash = "sha256:e50022f28b82a86313e54501317a1db64bf8f8d036ff8cfe5ca7e47675454aff", size = 123312, upload-time = "2026-10-06T17:24:15.054Z" }, +] From 58f57e5998b357866191dc37830711e21f909a17 Mon Sep 17 00:00:00 2001 From: Francesco Nazzaro Date: Tue, 27 Oct 2026 09:29:09 +0100 Subject: [PATCH 2/9] add .local to gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 69d9aa1..5294507 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .claude +.local* .venv __pycache__ \ No newline at end of file From 9b36fe8343134e753184e71d47463b92869b4090 Mon Sep 17 00:00:00 2001 From: Francesco Nazzaro Date: Tue, 27 Oct 2026 09:31:01 +0100 Subject: [PATCH 3/9] fix ignore local files --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 5294507..0f18a21 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ .claude -.local* +*local* .venv __pycache__ \ No newline at end of file From 3d7c1e1622ae3a4717f34f4d0abe8ea66f10c4df Mon Sep 17 00:00:00 2001 From: Francesco Nazzaro Date: Tue, 27 Oct 2026 10:23:43 +0100 Subject: [PATCH 4/9] rename vault to project --- src/ci_cd/get_kubeconfig.py | 37 +++++++++++++++---------------------- 1 file changed, 15 insertions(+), 22 deletions(-) mode change 100644 => 100755 src/ci_cd/get_kubeconfig.py diff --git a/src/ci_cd/get_kubeconfig.py b/src/ci_cd/get_kubeconfig.py old mode 100644 new mode 100755 index dd5502c..19d2398 --- a/src/ci_cd/get_kubeconfig.py +++ b/src/ci_cd/get_kubeconfig.py @@ -6,15 +6,12 @@ import json -import logging import pathlib import subprocess from typing import Annotated import typer -logger = logging.getLogger(__name__) - app = typer.Typer() @@ -38,7 +35,7 @@ def get_vault_id(name) -> str: vaults = run_pass_cli("vault", "list")["vaults"] for vault in vaults: if vault["name"] == name: - logger.info(f"Found vault {name!r} with ID {vault['share_id']!r}") + print(f"Found vault {name!r} with ID {vault['share_id']!r}") return vault["share_id"] raise ValueError(f"vault {name!r} not found") @@ -54,13 +51,13 @@ def get_item_id(vault_id: str, item_name: str) -> str: items = run_pass_cli("item", "list", f"--share-id={vault_id}")["items"] for item in items: if item["title"] == item_name: - logger.info(f"Found item {item_name!r} with ID {item['id']!r} in vault {vault_id!r}") + print(f"Found item {item_name!r} with ID {item['id']!r} in vault {vault_id!r}") return item["id"] raise ValueError(f"item {item_name!r} not found in vault {vault_id!r}") def get_attachment_id( - vault_id: str, item_id: str, attachment_name: str = "kubeconfig.yaml" + vault_id: str, item_id: str ) -> str: """ Get the attachment ID for a given vault ID, item ID, and attachment name using the `pass-cli` command-line tool. @@ -68,18 +65,13 @@ def get_attachment_id( Args: vault_id (str): The ID of the vault containing the item. item_id (str): The ID of the item containing the attachment. - attachment_name (str): The name of the attachment to retrieve the ID for. """ attachments = run_pass_cli( "item", "view", f"--share-id={vault_id}", f"--item-id={item_id}" )["attachments"] - for attachment in attachments: - if attachment["content"]["name"] == attachment_name: - logger.info(f"Found attachment {attachment_name!r} with ID {attachment['id']!r} in item {item_id!r} of vault {vault_id!r}") - return attachment["id"] - raise ValueError( - f"attachment {attachment_name!r} not found in item {item_id!r} of vault {vault_id!r}" - ) + assert len(attachments) == 1, f"Expected exactly one attachment in item {item_id!r} of vault {vault_id!r}, but found {len(attachments)}" + print(f"Found attachment {attachments[0]['content']['name']!r} with ID {attachments[0]['id']!r} in item {item_id!r} of vault {vault_id!r}") + return attachments[0]["id"] def download_kubeconfig( @@ -94,7 +86,7 @@ def download_kubeconfig( attachment_id (str): The ID of the attachment to download. output_path (pathlib.Path): The path where the kubeconfig file will be saved. """ - logger.info(f"Downloading kubeconfig from vault {vault_id!r}, item {item_id!r}, attachment {attachment_id!r}") + print(f"Downloading kubeconfig from vault {vault_id!r}, item {item_id!r}, attachment {attachment_id!r}") output_path.parent.mkdir(parents=True, exist_ok=True) subprocess.run( [ @@ -108,19 +100,21 @@ def download_kubeconfig( f"--output={output_path}", ], check=True, + capture_output=True, ) + print(f"Downloaded kubeconfig to {output_path.name!r}") return str(output_path) @app.command() def main( - vault: Annotated[ + project: Annotated[ str, typer.Option( - "--vault", - "-v", - envvar="CICD_VAULT_NAME", - help="Name of the vault containing the kubeconfig.", + "--project", + "-p", + envvar="CICD_PROJECT", + help="Name of the project containing the kubeconfig.", ), ], environment: Annotated[ @@ -139,8 +133,7 @@ def main( ), ] = None, ): - logging.basicConfig(level=logging.INFO) - vault_id = get_vault_id(vault) + vault_id = get_vault_id(project) item_id = get_item_id(vault_id, environment) attachment_id = get_attachment_id(vault_id, item_id) if output_path is None: From 48d5217c9e45ee779a9ec0f4133e42d071a9496c Mon Sep 17 00:00:00 2001 From: Francesco Nazzaro Date: Tue, 27 Oct 2026 10:43:45 +0100 Subject: [PATCH 5/9] improve logging --- src/ci_cd/get_kubeconfig.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/ci_cd/get_kubeconfig.py b/src/ci_cd/get_kubeconfig.py index 19d2398..cd4049c 100755 --- a/src/ci_cd/get_kubeconfig.py +++ b/src/ci_cd/get_kubeconfig.py @@ -6,12 +6,15 @@ import json +import logging import pathlib import subprocess from typing import Annotated import typer +logger = logging.getLogger(__name__) + app = typer.Typer() @@ -35,7 +38,7 @@ def get_vault_id(name) -> str: vaults = run_pass_cli("vault", "list")["vaults"] for vault in vaults: if vault["name"] == name: - print(f"Found vault {name!r} with ID {vault['share_id']!r}") + logger.debug(f"Found vault {name!r} with ID {vault['share_id']!r}") return vault["share_id"] raise ValueError(f"vault {name!r} not found") @@ -51,7 +54,7 @@ def get_item_id(vault_id: str, item_name: str) -> str: items = run_pass_cli("item", "list", f"--share-id={vault_id}")["items"] for item in items: if item["title"] == item_name: - print(f"Found item {item_name!r} with ID {item['id']!r} in vault {vault_id!r}") + logger.debug(f"Found item {item_name!r} with ID {item['id']!r} in vault {vault_id!r}") return item["id"] raise ValueError(f"item {item_name!r} not found in vault {vault_id!r}") @@ -70,7 +73,7 @@ def get_attachment_id( "item", "view", f"--share-id={vault_id}", f"--item-id={item_id}" )["attachments"] assert len(attachments) == 1, f"Expected exactly one attachment in item {item_id!r} of vault {vault_id!r}, but found {len(attachments)}" - print(f"Found attachment {attachments[0]['content']['name']!r} with ID {attachments[0]['id']!r} in item {item_id!r} of vault {vault_id!r}") + logger.debug(f"Found attachment {attachments[0]['content']['name']!r} with ID {attachments[0]['id']!r} in item {item_id!r} of vault {vault_id!r}") return attachments[0]["id"] @@ -86,7 +89,7 @@ def download_kubeconfig( attachment_id (str): The ID of the attachment to download. output_path (pathlib.Path): The path where the kubeconfig file will be saved. """ - print(f"Downloading kubeconfig from vault {vault_id!r}, item {item_id!r}, attachment {attachment_id!r}") + logger.debug(f"Downloading kubeconfig from vault {vault_id!r}, item {item_id!r}, attachment {attachment_id!r}") output_path.parent.mkdir(parents=True, exist_ok=True) subprocess.run( [ @@ -102,7 +105,7 @@ def download_kubeconfig( check=True, capture_output=True, ) - print(f"Downloaded kubeconfig to {output_path.name!r}") + logger.info(f"Downloaded kubeconfig to {output_path.name!r}") return str(output_path) @@ -133,11 +136,13 @@ def main( ), ] = None, ): + logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") + if output_path is None: + output_path = pathlib.Path(f"local.{environment.replace(' ', '_')}.kubeconfig.yaml") + logger.info(f"Downloading kubeconfig for project {project!r} and environment {environment!r} to {output_path.name!r}") vault_id = get_vault_id(project) item_id = get_item_id(vault_id, environment) attachment_id = get_attachment_id(vault_id, item_id) - if output_path is None: - output_path = pathlib.Path(f"local.{environment.replace(' ', '_')}.kubeconfig.yaml") download_kubeconfig(vault_id, item_id, attachment_id, output_path) From a53a59a0f2a4aa67fb2f6f43be15c686784bebca Mon Sep 17 00:00:00 2001 From: Francesco Nazzaro Date: Tue, 27 Oct 2026 10:46:11 +0100 Subject: [PATCH 6/9] rename script --- pyproject.toml | 2 +- src/ci_cd/{get_kubeconfig.py => get_kube.py} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename src/ci_cd/{get_kubeconfig.py => get_kube.py} (100%) diff --git a/pyproject.toml b/pyproject.toml index c22ee8b..d34bba1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ dependencies = [ [project.scripts] git-clone-ref = "ci_cd.git_clone_ref:app" -get-kubeconfig = "ci_cd.get_kubeconfig:app" +get-kube = "ci_cd.get_kube:app" [build-system] requires = ["uv_build>=0.12.7,<0.13.0"] diff --git a/src/ci_cd/get_kubeconfig.py b/src/ci_cd/get_kube.py similarity index 100% rename from src/ci_cd/get_kubeconfig.py rename to src/ci_cd/get_kube.py From 7757d42f6272b41c768519ad8812919b120d45f7 Mon Sep 17 00:00:00 2001 From: Francesco Nazzaro Date: Tue, 27 Oct 2026 10:56:05 +0100 Subject: [PATCH 7/9] add dependency and no_args_is_help --- src/ci_cd/get_kube.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/ci_cd/get_kube.py b/src/ci_cd/get_kube.py index cd4049c..297b38a 100755 --- a/src/ci_cd/get_kube.py +++ b/src/ci_cd/get_kube.py @@ -1,7 +1,9 @@ #!/usr/bin/env python # /// script # requires-python = ">=3.12" -# dependencies = [] +# dependencies = [ +# "typer", +# ] # /// @@ -109,7 +111,7 @@ def download_kubeconfig( return str(output_path) -@app.command() +@app.command(no_args_is_help=True) def main( project: Annotated[ str, From bd34be4cb0ae04b121eaddc9780e42748500df63 Mon Sep 17 00:00:00 2001 From: Francesco Nazzaro Date: Tue, 27 Oct 2026 12:07:59 +0100 Subject: [PATCH 8/9] review script with claude --- src/ci_cd/get_kube.py | 48 ++++++++++++++++++++++++++++++------------- 1 file changed, 34 insertions(+), 14 deletions(-) diff --git a/src/ci_cd/get_kube.py b/src/ci_cd/get_kube.py index 297b38a..d061bc1 100755 --- a/src/ci_cd/get_kube.py +++ b/src/ci_cd/get_kube.py @@ -30,7 +30,7 @@ def run_pass_cli(*args: str) -> dict: return json.loads(result.stdout) -def get_vault_id(name) -> str: +def get_vault_id(name: str) -> str: """ Get the vault ID for a given name using the `pass-cli` command-line tool. @@ -61,11 +61,9 @@ def get_item_id(vault_id: str, item_name: str) -> str: raise ValueError(f"item {item_name!r} not found in vault {vault_id!r}") -def get_attachment_id( - vault_id: str, item_id: str -) -> str: +def get_attachment_id(vault_id: str, item_id: str) -> str: """ - Get the attachment ID for a given vault ID, item ID, and attachment name using the `pass-cli` command-line tool. + Get the ID of the single attachment of a given item using the `pass-cli` command-line tool. Args: vault_id (str): The ID of the vault containing the item. @@ -74,14 +72,17 @@ def get_attachment_id( attachments = run_pass_cli( "item", "view", f"--share-id={vault_id}", f"--item-id={item_id}" )["attachments"] - assert len(attachments) == 1, f"Expected exactly one attachment in item {item_id!r} of vault {vault_id!r}, but found {len(attachments)}" + if len(attachments) != 1: + raise ValueError( + f"expected exactly one attachment in item {item_id!r} of vault {vault_id!r}, found {len(attachments)}" + ) logger.debug(f"Found attachment {attachments[0]['content']['name']!r} with ID {attachments[0]['id']!r} in item {item_id!r} of vault {vault_id!r}") return attachments[0]["id"] def download_kubeconfig( vault_id: str, item_id: str, attachment_id: str, output_path: pathlib.Path -) -> str: +) -> None: """ Download a kubeconfig file from a vault and save it to the specified output path. @@ -106,9 +107,10 @@ def download_kubeconfig( ], check=True, capture_output=True, + text=True, ) - logger.info(f"Downloaded kubeconfig to {output_path.name!r}") - return str(output_path) + output_path.chmod(0o600) + logger.info(f"Downloaded kubeconfig to {str(output_path)!r}") @app.command(no_args_is_help=True) @@ -141,11 +143,29 @@ def main( logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") if output_path is None: output_path = pathlib.Path(f"local.{environment.replace(' ', '_')}.kubeconfig.yaml") - logger.info(f"Downloading kubeconfig for project {project!r} and environment {environment!r} to {output_path.name!r}") - vault_id = get_vault_id(project) - item_id = get_item_id(vault_id, environment) - attachment_id = get_attachment_id(vault_id, item_id) - download_kubeconfig(vault_id, item_id, attachment_id, output_path) + try: + logged_in = subprocess.run(["pass-cli", "info"], capture_output=True, text=True, check=False) + if logged_in.returncode != 0: + logger.error( + "You are not logged in to pass-cli. Please log in with 'pass-cli login' and try again." + ) + raise typer.Exit(1) + logger.info(f"Downloading kubeconfig for project {project!r} and environment {environment!r} to {str(output_path)!r}") + vault_id = get_vault_id(project) + item_id = get_item_id(vault_id, environment) + attachment_id = get_attachment_id(vault_id, item_id) + download_kubeconfig(vault_id, item_id, attachment_id, output_path) + except FileNotFoundError as e: + if e.filename != "pass-cli": + raise + logger.error("pass-cli is not installed or not on PATH.") + raise typer.Exit(1) + except subprocess.CalledProcessError as e: + logger.error(f"pass-cli failed: {(e.stderr or '').strip() or e}") + raise typer.Exit(1) + except ValueError as e: + logger.error(str(e)) + raise typer.Exit(1) if __name__ == "__main__": From 5680e7d95769c1aa520fce5dfa1c942adddc2c07 Mon Sep 17 00:00:00 2001 From: Francesco Nazzaro Date: Tue, 27 Oct 2026 12:35:07 +0100 Subject: [PATCH 9/9] update README --- README.md | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/README.md b/README.md index 74e0525..676f3f0 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,53 @@ This action can be used in workflows to ensure that the `uv.lock` file is correc repo-path: path/to/repository ~~~ +## copy-git-clone-script Action + +A GitHub Action that copies the `git-clone-ref.py` script into a directory of the calling repository. + +### Usage + +**Inputs:** +- **`target-path`** (required): The directory where `git-clone-ref.py` should be copied + +**Example workflow:** +~~~yaml + - name: Copy git clone script + uses: bopen/ci-cd/copy-git-clone-script@main + with: + target-path: scripts +~~~ + +## Scripts + +The scripts backing the actions live in `src/ci_cd` and are packaged as a uv project, so they can also be run locally: + +- **`git-clone-ref`**: clones multiple repositories (used by the `git-clone` action) +- **`get-kube`**: downloads a kubeconfig from Proton Pass + +### get-kube + +Downloads the kubeconfig of a Kubernetes environment stored in Proton Pass, using the `pass-cli` command-line tool. + +Each project is a Proton Pass vault, and each environment is an item in that vault with the kubeconfig as its only attachment. + +**Requirements:** +- `pass-cli` installed and on `PATH` +- Logged in with `pass-cli login` + +**Options:** +- **`-p, --project`** (required, env `CICD_PROJECT`): Name of the vault containing the kubeconfig +- **`-e, --environment`** (required, env `CICD_ENVIRONMENT`): Name of the item holding the kubeconfig +- **`-o, --output-path`** (optional, env `CICD_OUTPUT_PATH`): Where to save the kubeconfig. Defaults to `local..kubeconfig.yaml` in the current directory, with spaces replaced by `_` + +**Example:** +~~~bash +uv run get-kube -p my-project -e "production eu" +export KUBECONFIG=$PWD/local.production_eu.kubeconfig.yaml +~~~ + +The file is saved readable only by its owner (`0600`), since it contains cluster credentials. Running `uv run get-kube` with no arguments prints the help. + ## License ```