Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
.claude
*local*
.venv
__pycache__
1 change: 1 addition & 0 deletions .python-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
3.14
47 changes: 47 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<environment>.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

```
Expand Down
4 changes: 2 additions & 2 deletions copy-git-clone-script/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
2 changes: 1 addition & 1 deletion git-clone/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
21 changes: 21 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -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-kube = "ci_cd.get_kube:app"

[build-system]
requires = ["uv_build>=0.12.7,<0.13.0"]
build-backend = "uv_build"
Empty file added src/ci_cd/__init__.py
Empty file.
172 changes: 172 additions & 0 deletions src/ci_cd/get_kube.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
#!/usr/bin/env python
# /// script
# requires-python = ">=3.12"
# dependencies = [
# "typer",
# ]
# ///


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) -> 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.debug(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.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}")


def get_attachment_id(vault_id: str, item_id: str) -> str:
"""
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.
item_id (str): The ID of the item containing the attachment.
"""
attachments = run_pass_cli(
"item", "view", f"--share-id={vault_id}", f"--item-id={item_id}"
)["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
) -> None:
"""
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.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(
[
"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,
capture_output=True,
text=True,
)
output_path.chmod(0o600)
logger.info(f"Downloaded kubeconfig to {str(output_path)!r}")


@app.command(no_args_is_help=True)
def main(
project: Annotated[
str,
typer.Option(
"--project",
"-p",
envvar="CICD_PROJECT",
help="Name of the project 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, format="%(levelname)s: %(message)s")
if output_path is None:
output_path = pathlib.Path(f"local.{environment.replace(' ', '_')}.kubeconfig.yaml")
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__":
app()
3 changes: 2 additions & 1 deletion scripts/git-clone-ref.py → src/ci_cd/git_clone_ref.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#!/usr/bin/env python
# /// script
# requires-python = ">=3.12"
# dependencies = [
# "gitpython",
# "typer",
Expand All @@ -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__)
Expand Down
Loading
Loading