feat: add release workflow and binary installer (#48)
* feat: add multi-arch release workflow and installer
This commit is contained in:
parent
0e9bd11d5c
commit
3eed7772ef
|
|
@ -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
|
||||
14
README.md
14
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
|
||||
|
|
|
|||
|
|
@ -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", "<unknown>") 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)
|
||||
Loading…
Reference in New Issue