diff --git a/.github/workflows/pypi.yml b/.github/workflows/publish-pypi.yml similarity index 68% rename from .github/workflows/pypi.yml rename to .github/workflows/publish-pypi.yml index cd66123..70307bc 100644 --- a/.github/workflows/pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -8,7 +8,7 @@ name: Publish to PyPI on: push: tags: - - 'v*' # v1.0.0 のようなタグで実行 + - 'v*' # Trigger on any tag push workflow_dispatch: jobs: @@ -33,14 +33,30 @@ jobs: python -m pip install --upgrade pip pip install --upgrade setuptools wheel build twine - - name: Build and publish + - name: Check version format + id: check_version + run: | + if [[ "${GITHUB_REF##*/}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Valid version tag detected: ${GITHUB_REF##*/}" + echo "VERSION_VALID=true" >> $GITHUB_ENV + else + echo "Invalid version tag detected: ${GITHUB_REF##*/}" + echo "VERSION_VALID=false" >> $GITHUB_ENV + fi + + - name: Build for PyPI + run: | + python -m build + + - name: publish to PyPI + if: env.VERSION_VALID == 'true' env: TWINE_USERNAME: __token__ TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} run: | - python -m build twine upload --repository pypi dist/* + - name: Generate SHA256 run: | sha256sum dist/*.tar.gz > dist/fireblocks-cli-${GITHUB_REF##*/}.tar.gz.sha256 @@ -48,6 +64,7 @@ jobs: - name: Upload Release Assets to GitHub uses: softprops/action-gh-release@v2 with: + tag_name: ${{ github.ref_name }} files: | dist/*.tar.gz dist/*.tar.gz.sha256 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 27159e2..0847841 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -20,6 +20,13 @@ repos: pass_filenames: false always_run: true stages: [commit] + - repo: local + hooks: + - id: update-__init__.py + name: Sync __init__.py with pyproject.toml + entry: python ./scripts/sync_init_with_pyproject.py + language: python + files: pyproject.toml - repo: local hooks: - id: add-spdx diff --git a/docs/devops/release_workflow.md b/docs/devops/release_workflow.md new file mode 100644 index 0000000..423cc2e --- /dev/null +++ b/docs/devops/release_workflow.md @@ -0,0 +1,35 @@ + + + +# Release Workflow for fireblocks-cli + +This document outlines the release process for the **fireblocks-cli** project, covering both **PyPI** and **Homebrew** releases, and how they are handled through GitHub Actions. + +## Overview + +When a tag (e.g., `v0.1.8`) is pushed, the following actions are triggered: + +1. **PyPI release**: The Python package is built and uploaded to **PyPI** (source tar.gz and wheel). + +## Workflow Steps + +1. **Trigger**: A tag is pushed (e.g., `v0.1.8`) to GitHub. +2. **GitHub Actions**: + - **publish-pypi.yml**: This workflow builds the Python package and uploads it to PyPI. + + +# Additional Notes + +- The process can be automated completely, minimizing manual steps for each release. +- This workflow is designed for seamless integration with both **Homebrew** and **PyPI**, making it easy for developers and users alike to install the tool. + +--- + +# Future Improvements + +- Add version management for Homebrew (automatic update of version on Homebrew tap). +- Enhance testing and validation steps for both PyPI and Homebrew releases. diff --git a/fireblocks_cli/__init__.py b/fireblocks_cli/__init__.py index d4a2139..c4046af 100644 --- a/fireblocks_cli/__init__.py +++ b/fireblocks_cli/__init__.py @@ -4,7 +4,5 @@ # Author: Shohei KAMON -from importlib.metadata import version as get_version - -__version__ = get_version(__name__) +__version__ = "0.1.9" diff --git a/pyproject.toml b/pyproject.toml index a9fdce9..ed321db 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ [project] name = "fireblocks-cli" -version = "0.1.8" +version = "0.1.9" description = "An unofficial CLI for managing Fireblocks services." authors = [{ name = "Kamon Shohei", email = "cameong@stir.network" }] readme = "README.md" diff --git a/scripts/sync_init_with_pyproject.py b/scripts/sync_init_with_pyproject.py new file mode 100644 index 0000000..28db3b5 --- /dev/null +++ b/scripts/sync_init_with_pyproject.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 + +# SPDX-FileCopyrightText: 2025 Ethersecurity Inc. +# +# SPDX-License-Identifier: MPL-2.0 +# Author: Shohei KAMON + +import toml +import re + + +def update_version( + init_path: str = "fireblocks_cli/__init__.py", + pyproject_path: str = "pyproject.toml", +) -> None: + """pyproject.tomlのversionと__init__.pyのversionを同期する""" + + # 1. pyproject.toml を読み込む + with open(pyproject_path, "r") as f: + pyproject = toml.load(f) + pyproject_version = pyproject["project"]["version"] + + # 2. __init__.py を読み込む + with open(init_path, "r") as f: + lines = f.readlines() + + # 3. __init__.pyの中の__version__を探す + current_version = None + for line in lines: + match = re.match(r'^__version__\s*=\s*[\'"]([^\'"]+)[\'"]', line.strip()) + if match: + current_version = match.group(1) + break + + # 4. バージョンが同じなら何もしない + if current_version == pyproject_version: + print( + f"No update needed: {init_path} version {current_version} matches pyproject.toml version {pyproject_version}" + ) + return + + # 5. SPDXヘッダーのみ残して、後続を書き直す + header = [] + for line in lines: + if line.strip().startswith("#") or not line.strip(): + header.append(line) + else: + break + + # 6. ファイルを書き直す + with open(init_path, "w") as f: + f.writelines(header) + f.write("\n") + f.write(f'__version__ = "{pyproject_version}"\n') + + print(f"Updated {init_path}: {current_version} → {pyproject_version}") + + +if __name__ == "__main__": + update_version() diff --git a/scripts/update_homebrew_formula.py b/scripts/update_homebrew_formula.py new file mode 100644 index 0000000..5244719 --- /dev/null +++ b/scripts/update_homebrew_formula.py @@ -0,0 +1,170 @@ +# SPDX-FileCopyrightText: 2025 Ethersecurity Inc. +# +# SPDX-License-Identifier: MPL-2.0 +# Author: Shohei KAMON + +import tomllib +import requests +import hashlib +import os +import re + +FORMULA_TEMPLATE = """class {formula_class_name} < Formula + include Language::Python::Virtualenv + + desc "{desc}" + homepage "{homepage}" + url "{sdist_url}" + sha256 "{sdist_sha256}" + license "{license}" + + depends_on "python@3.11" + +{resources} + def install + virtualenv_install_with_resources + end + + test do + system "#{{bin}}/{command_name}", "--version" + end +end +""" + +# 手動で追加したいパッケージ一覧 +extra_packages = [ + "requests", + "urllib3", + "fireblocks_sdk", + "idna", + "certifi", + "PyJWT", + "chardet", +] + + +def get_pypi_metadata(package_name): + response = requests.get(f"https://pypi.org/pypi/{package_name}/json") + response.raise_for_status() + return response.json() + + +def get_sdist_info(pypi_data, package_name="(unknown)"): + for file in pypi_data["urls"]: + if file["packagetype"] == "sdist": + return file["url"], file["digests"]["sha256"] + print(f"Warning: No sdist found for {package_name}, skipping.") + return None, None + + +def get_sha256_from_url(url): + response = requests.get(url) + response.raise_for_status() + return hashlib.sha256(response.content).hexdigest() + + +def generate_resource_block(package_name): + data = get_pypi_metadata(package_name) + sdist_info = get_sdist_info(data, package_name) + if sdist_info == (None, None): + return "" # resourceを生成しない + sdist_url, sdist_sha256 = sdist_info + return f""" resource "{package_name}" do\n url "{sdist_url}"\n sha256 "{sdist_sha256}"\n end\n""" + + +def sanitize_formula_class_name(name): + parts = re.split(r"[-_]", name) + return "".join(part.capitalize() for part in parts) + + +def normalize_package_name(dep_string): + return re.split(r"[<>=\[]", dep_string)[0] + + +def extract_extras(dep_string): + match = re.search(r"\[([^\]]+)\]", dep_string) + if match: + extras = match.group(1) + return [e.strip() for e in extras.split(",")] + return [] + + +def extract_dependencies_with_extras(project_name, extras=[]): + data = get_pypi_metadata(project_name) + requires_dist = data["info"].get("requires_dist", []) + result = [] + + for dep in requires_dist: + if "; extra ==" in dep: + extra_match = re.search(r"extra == [\'\"]([^\'\"]+)[\'\"]", dep) + if extra_match and extra_match.group(1) in extras: + result.append(dep.split(";")[0].strip()) + else: + result.append(dep.split(";")[0].strip()) + + return result + + +def main(): + with open("pyproject.toml", "rb") as f: + pyproject = tomllib.load(f) + + project = pyproject["project"] + project_name = project["name"] + version = project["version"] + homepage = project.get("urls", {}).get( + "Homepage", "https://github.com/stirnetwork/fireblocks-cli" + ) + license_name = project.get("license", {}).get("text", "MPL-2.0") + desc = project.get("description", f"{project_name} CLI tool") + + pypi_data = get_pypi_metadata(project_name) + sdist_url, sdist_sha256 = get_sdist_info(pypi_data) + + dependencies = [] + for dep in project.get("dependencies", []): + dep_name = normalize_package_name(dep) + extras = extract_extras(dep) + dependencies.append((dep_name, extras)) + + for extra in extra_packages: + dependencies.append((extra, [])) + + resources = "" + seen = set() + for dep_name, extras in dependencies: + if dep_name in seen: + continue + seen.add(dep_name) + resources += generate_resource_block(dep_name) + + if extras: + for sub_dep in extract_dependencies_with_extras(dep_name, extras): + sub_dep_name = normalize_package_name(sub_dep) + if sub_dep_name not in seen: + seen.add(sub_dep_name) + resources += generate_resource_block(sub_dep_name) + + if not resources: + raise ValueError("No resources generated. Check dependency parsing.") + + formula_content = FORMULA_TEMPLATE.format( + formula_class_name=sanitize_formula_class_name(project_name), + desc=desc, + homepage=homepage, + sdist_url=sdist_url, + sdist_sha256=sdist_sha256, + license=license_name, + resources=resources, + command_name=project_name, + ) + + formula_filename = f"{project_name}.rb" + with open(formula_filename, "w") as f: + f.write(formula_content) + + print(f"Formula generated: {formula_filename}") + + +if __name__ == "__main__": + main()