From 3eed7772efb184861ca61451738589638fd1f22e Mon Sep 17 00:00:00 2001 From: existedinnettw Date: Sat, 11 Apr 2026 19:39:32 +0800 Subject: [PATCH] feat: add release workflow and binary installer (#48) * feat: add multi-arch release workflow and installer --- .github/workflows/release.yaml | 80 ++++++++++++ README.md | 14 +++ scripts/install.py | 223 +++++++++++++++++++++++++++++++++ 3 files changed, 317 insertions(+) create mode 100644 .github/workflows/release.yaml create mode 100755 scripts/install.py diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..57c1be1 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,80 @@ +name: release + +on: + release: + types: + - published + +permissions: + contents: write + +jobs: + build: + name: Build ${{ matrix.target_os }}-${{ matrix.arch }} + runs-on: ${{ matrix.runs_on }} + strategy: + fail-fast: false + matrix: + include: + - runs_on: ubuntu-24.04 + target_os: linux + arch: x64 + ext: '' + - runs_on: ubuntu-24.04-arm + target_os: linux + arch: arm64 + ext: '' + - runs_on: macos-14 + target_os: macos + arch: arm64 + ext: '' + - runs_on: windows-2022 + target_os: windows + arch: x64 + ext: .exe + + steps: + - uses: actions/checkout@v4 + + - uses: dart-lang/setup-dart@v1 + + - name: Install dependencies + run: dart pub get + + - name: Build executable + run: dart compile exe bin/code_work_spawner.dart -o dist/code_work_spawner${{ matrix.ext }} + + - name: Prepare release asset + shell: bash + run: | + mkdir -p release-assets + asset_name="code_work_spawner-${{ github.ref_name }}-${{ matrix.target_os }}-${{ matrix.arch }}${{ matrix.ext }}" + cp "dist/code_work_spawner${{ matrix.ext }}" "release-assets/${asset_name}" + + - name: Upload build artifact + uses: actions/upload-artifact@v4 + with: + name: code_work_spawner-${{ matrix.target_os }}-${{ matrix.arch }} + path: release-assets/* + if-no-files-found: error + + publish: + name: Upload release assets + runs-on: ubuntu-latest + needs: build + steps: + - name: Download build artifacts + uses: actions/download-artifact@v4 + with: + path: release-assets + merge-multiple: true + + - name: Generate checksums + run: | + cd release-assets + sha256sum * > SHA256SUMS.txt + + - name: Upload assets to release + env: + GH_TOKEN: ${{ github.token }} + run: gh release upload "${{ github.ref_name }}" release-assets/* --clobber diff --git a/README.md b/README.md index a1e723d..15ebd21 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,20 @@ dart run bin/code_work_spawner.dart \ --tea-command /usr/bin/tea ``` +## Install Binary + +Install the latest matching release binary from GitHub Releases: + +```bash +uv run https://github.com/existedinnettw/code_work_spawner/scripts/install.py +``` + +Install a specific version: + +```bash +uv run https://github.com/existedinnettw/code_work_spawner/scripts/install.py --version v1.0.0 +``` + ## Testing ```bash diff --git a/scripts/install.py b/scripts/install.py new file mode 100755 index 0000000..fe392d6 --- /dev/null +++ b/scripts/install.py @@ -0,0 +1,223 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.11" +# dependencies = [ +# "httpx>=0.28.1", +# ] +# /// + +import argparse +import os +import platform +import shutil +import stat +import sys +import tempfile +from pathlib import Path + +import httpx + + +REPO = "existedinnettw/code_work_spawner" +BINARY_NAME = "code_work_spawner" + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Install code_work_spawner from GitHub Releases." + ) + parser.add_argument( + "--version", + default="latest", + help="Release tag to install (default: latest).", + ) + parser.add_argument( + "--bin-dir", + default=str(default_bin_dir()), + help="Directory to install the binary into.", + ) + parser.add_argument( + "--repo", + default=REPO, + help="GitHub repository in owner/name format.", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Print the selected asset and destination without downloading.", + ) + return parser.parse_args() + + +def default_bin_dir() -> Path: + if os.name == "nt": + local_app_data = os.environ.get("LOCALAPPDATA") + if local_app_data: + return Path(local_app_data) / "Programs" / "code_work_spawner" / "bin" + return ( + Path.home() / "AppData" / "Local" / "Programs" / "code_work_spawner" / "bin" + ) + return Path.home() / ".local" / "bin" + + +def detect_target() -> tuple[str, str, str]: + system = platform.system().lower() + machine = platform.machine().lower() + + if system.startswith("linux"): + target_os = "linux" + extension = "" + elif system.startswith("darwin"): + target_os = "macos" + extension = "" + elif system.startswith("windows"): + target_os = "windows" + extension = ".exe" + else: + raise RuntimeError(f"Unsupported operating system: {platform.system()}") + + if machine in {"x86_64", "amd64"}: + arch = "x64" + elif machine in {"aarch64", "arm64"}: + arch = "arm64" + else: + raise RuntimeError(f"Unsupported architecture: {platform.machine()}") + + if target_os == "windows" and arch != "x64": + raise RuntimeError( + "No published Windows release asset for this architecture. Expected x64." + ) + + return target_os, arch, extension + + +def release_api_url(repo: str, version: str) -> str: + if version == "latest": + return f"https://api.github.com/repos/{repo}/releases/latest" + from urllib.parse import quote + + encoded_tag = quote(version, safe="") + return f"https://api.github.com/repos/{repo}/releases/tags/{encoded_tag}" + + +def download_release_metadata(repo: str, version: str) -> dict: + url = release_api_url(repo, version) + try: + response = httpx.get( + url, + headers={ + "Accept": "application/vnd.github+json", + "User-Agent": "code_work_spawner-install-script", + }, + timeout=30.0, + follow_redirects=True, + ) + response.raise_for_status() + return response.json() + except httpx.HTTPStatusError as exc: + raise RuntimeError( + f"Failed to fetch release metadata ({exc.response.status_code}): {exc.response.text}" + ) from exc + except httpx.HTTPError as exc: + raise RuntimeError(f"Failed to fetch release metadata: {exc}") from exc + + +def expected_asset_name(tag: str, target_os: str, arch: str, extension: str) -> str: + return f"{BINARY_NAME}-{tag}-{target_os}-{arch}{extension}" + + +def select_asset(metadata: dict, asset_name: str) -> dict: + for asset in metadata.get("assets", []): + if asset.get("name") == asset_name: + return asset + available = ", ".join( + asset.get("name", "") for asset in metadata.get("assets", []) + ) + raise RuntimeError( + f"Could not find release asset {asset_name!r}. Available assets: {available}" + ) + + +def download_asset(asset: dict, destination: Path) -> None: + download_url = asset.get("browser_download_url") + if not download_url: + raise RuntimeError("Release asset is missing browser_download_url.") + + try: + with httpx.stream( + "GET", + download_url, + headers={"User-Agent": "code_work_spawner-install-script"}, + timeout=60.0, + follow_redirects=True, + ) as response: + response.raise_for_status() + with destination.open("wb") as output: + for chunk in response.iter_bytes(chunk_size=1024 * 64): + output.write(chunk) + except httpx.HTTPStatusError as exc: + raise RuntimeError( + f"Failed to download asset ({exc.response.status_code}): {exc.response.text}" + ) from exc + except httpx.HTTPError as exc: + raise RuntimeError(f"Failed to download asset: {exc}") from exc + + +def install_binary(download_path: Path, destination_path: Path) -> None: + destination_path.parent.mkdir(parents=True, exist_ok=True) + shutil.move(str(download_path), str(destination_path)) + if os.name != "nt": + mode = destination_path.stat().st_mode + destination_path.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + + +def warn_if_not_on_path(bin_dir: Path) -> None: + path_env = os.environ.get("PATH", "") + path_entries = [ + Path(entry).resolve() for entry in path_env.split(os.pathsep) if entry + ] + if bin_dir.resolve() not in path_entries: + print( + f"Warning: {bin_dir} is not in PATH. Add it to run {BINARY_NAME} directly.", + file=sys.stderr, + ) + + +def main() -> int: + args = parse_args() + target_os, arch, extension = detect_target() + metadata = download_release_metadata(args.repo, args.version) + tag = metadata.get("tag_name") + if not tag: + raise RuntimeError("Release metadata is missing tag_name.") + + asset_name = expected_asset_name(tag, target_os, arch, extension) + asset = select_asset(metadata, asset_name) + + bin_dir = Path(args.bin_dir).expanduser() + binary_filename = BINARY_NAME + extension + destination_path = bin_dir / binary_filename + + if args.dry_run: + print(f"repo: {args.repo}") + print(f"tag: {tag}") + print(f"asset: {asset_name}") + print(f"install_to: {destination_path}") + return 0 + + with tempfile.TemporaryDirectory(prefix="code_work_spawner-install-") as temp_dir: + temp_path = Path(temp_dir) / binary_filename + download_asset(asset, temp_path) + install_binary(temp_path, destination_path) + + print(f"Installed {BINARY_NAME} {tag} to {destination_path}") + warn_if_not_on_path(bin_dir) + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except RuntimeError as exc: + print(f"Error: {exc}", file=sys.stderr) + raise SystemExit(1)