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
23 changes: 20 additions & 3 deletions .github/workflows/pypi.yml → .github/workflows/publish-pypi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ name: Publish to PyPI
on:
push:
tags:
- 'v*' # v1.0.0 のようなタグで実行
- 'v*' # Trigger on any tag push
workflow_dispatch:

jobs:
Expand All @@ -33,21 +33,38 @@ 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

- 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
Expand Down
7 changes: 7 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
35 changes: 35 additions & 0 deletions docs/devops/release_workflow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<!--
SPDX-FileCopyrightText: 2025 Ethersecurity Inc.

SPDX-License-Identifier: MPL-2.0
-->
<!-- Author: Shohei KAMON <cameong@stir.network> -->

# 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.
4 changes: 1 addition & 3 deletions fireblocks_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,5 @@

# Author: Shohei KAMON <cameong@stir.network>

from importlib.metadata import version as get_version


__version__ = get_version(__name__)
__version__ = "0.1.9"
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
60 changes: 60 additions & 0 deletions scripts/sync_init_with_pyproject.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
#!/usr/bin/env python3

# SPDX-FileCopyrightText: 2025 Ethersecurity Inc.
#
# SPDX-License-Identifier: MPL-2.0
# Author: Shohei KAMON <cameong@stir.network>

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()
170 changes: 170 additions & 0 deletions scripts/update_homebrew_formula.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
# SPDX-FileCopyrightText: 2025 Ethersecurity Inc.
#
# SPDX-License-Identifier: MPL-2.0
# Author: Shohei KAMON <cameong@stir.network>

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()