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
3 changes: 2 additions & 1 deletion .github/workflows/build-installers.yml
Original file line number Diff line number Diff line change
Expand Up @@ -107,12 +107,13 @@ jobs:

- name: Build macOS package
env:
GITHUB_TOKEN: ${{ github.token }}
CLIPABIT_AUTH0_DOMAIN: ${{ secrets.CLIPABIT_AUTH0_DOMAIN }}
CLIPABIT_AUTH0_CLIENT_ID: ${{ secrets.CLIPABIT_AUTH0_CLIENT_ID }}
CLIPABIT_AUTH0_AUDIENCE: ${{ secrets.CLIPABIT_AUTH0_AUDIENCE }}
CLIPABIT_ENVIRONMENT: ${{ secrets.CLIPABIT_ENVIRONMENT || 'prod' }}
run: |
./build-pkg.sh
./build-pkg.sh --local plugin

- name: Upload macOS installer
uses: actions/upload-artifact@v4
Expand Down
62 changes: 46 additions & 16 deletions build-pkg.sh
Original file line number Diff line number Diff line change
Expand Up @@ -179,22 +179,6 @@ echo " pip: $("${BUNDLED_PYTHON}" -m pip --version 2>&1)"
# -------------------------------------------------------------------
# Plugin retrieval
# -------------------------------------------------------------------
# Fetch the latest release tag metadata if in staging/prod environment.
# This ensures even local builds are correctly labeled with the tag they represent.
if [ "$CLIPABIT_ENVIRONMENT" = "staging" ]; then
echo " Staging environment detected. Fetching latest pre-release/release metadata..."
API_URL="https://api.github.com/repos/ClipABit/Resolve-Plugin/releases"
LATEST_TAG=$(curl -s "$API_URL" | jq -r '[.[] | select(.prerelease == true)][0].tag_name')
elif [ "$CLIPABIT_ENVIRONMENT" = "prod" ]; then
echo " Production environment. Fetching latest production release metadata..."
API_URL="https://api.github.com/repos/ClipABit/Resolve-Plugin/releases/latest"
LATEST_TAG=$(curl -s "$API_URL" | jq -r '.tag_name')
fi

if [ -n "$LATEST_TAG" ] && [ "$LATEST_TAG" != "null" ]; then
echo " Latest release tag: ${LATEST_TAG}"
fi

PLUGIN_DIR="${SCRIPT_DIR}/plugin"

rm -rf "${PLUGIN_DIR}"
Expand All @@ -219,10 +203,56 @@ else
exit 1
fi

if [ "$CLIPABIT_ENVIRONMENT" = "staging" ]; then
echo " Staging environment detected. Fetching latest pre-release/release metadata..."
API_URL="https://api.github.com/repos/ClipABit/Resolve-Plugin/releases"
TAG_FILTER='[.[] | select(.prerelease == true)][0].tag_name'
elif [ "$CLIPABIT_ENVIRONMENT" = "prod" ]; then
echo " Production environment. Fetching latest production release metadata..."
API_URL="https://api.github.com/repos/ClipABit/Resolve-Plugin/releases/latest"
TAG_FILTER='.tag_name'
else
echo "ERROR: Unsupported CLIPABIT_ENVIRONMENT='${CLIPABIT_ENVIRONMENT}'. Expected 'prod' or 'staging'."
exit 1
fi

API_RESPONSE_FILE=$(mktemp)
CURL_AUTH_ARGS=()
if [ -n "${GITHUB_TOKEN}" ]; then
CURL_AUTH_ARGS=(-H "Authorization: Bearer ${GITHUB_TOKEN}")
fi

if ! curl --fail --silent --show-error --retry 3 --retry-delay 2 --retry-connrefused \
"${CURL_AUTH_ARGS[@]}" "$API_URL" -o "$API_RESPONSE_FILE"; then
rm -f "$API_RESPONSE_FILE"

# Fallback for prod: resolve the latest tag from the GitHub releases redirect.
# This avoids API rate-limit failures (403) on unauthenticated runners.
if [ "$CLIPABIT_ENVIRONMENT" = "prod" ]; then
echo " GitHub API request failed. Falling back to releases/latest redirect..."
REDIRECT_TAG=$(curl --fail --silent --show-error --location --write-out '%{url_effective}' --output /dev/null \
"https://github.com/ClipABit/Resolve-Plugin/releases/latest" | sed -E 's#.*/tag/##')
if [ -n "$REDIRECT_TAG" ] && [ "$REDIRECT_TAG" != "latest" ]; then
LATEST_TAG="$REDIRECT_TAG"
else
echo "ERROR: Failed to fetch release metadata from GitHub API: $API_URL"
exit 1
fi
else
echo "ERROR: Failed to fetch release metadata from GitHub API: $API_URL"
exit 1
fi
else
LATEST_TAG=$(jq -r "$TAG_FILTER" "$API_RESPONSE_FILE")
rm -f "$API_RESPONSE_FILE"
fi

if [ -z "$LATEST_TAG" ] || [ "$LATEST_TAG" = "null" ]; then
echo "ERROR: Could not fetch release tag from GitHub API: $API_URL"
echo "Hint: this can happen due to GitHub API rate limiting."
exit 1
fi
echo " Latest release tag: ${LATEST_TAG}"

ARCHIVE_URL="https://github.com/ClipABit/Resolve-Plugin/archive/refs/tags/${LATEST_TAG}.zip"

Expand Down
100 changes: 96 additions & 4 deletions clipabit-installer.spec
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,111 @@
# Build with: pyinstaller clipabit-installer.spec

# -*- mode: python ; coding: utf-8 -*-
import hashlib
import json
import os
import shutil
import tarfile
import urllib.request
from pathlib import Path

block_cipher = None

SPEC_FILE = globals().get('__file__', 'clipabit-installer.spec')
ROOT = Path(SPEC_FILE).resolve().parent
BUILD_DIR = ROOT / 'build'


def _resolve_plugin_dir() -> Path:
# Prefer staged plugin/ when present; fallback to repo frontend/plugin for local dev.
candidates = [ROOT / 'plugin', ROOT / 'frontend' / 'plugin']
for candidate in candidates:
if (candidate / 'clipabit.py').exists() and (candidate / 'pyproject.toml').exists():
return candidate
raise FileNotFoundError(
"Could not locate plugin directory. Expected 'plugin/' or 'frontend/plugin/' with clipabit.py and pyproject.toml"
)


def _sha256_file(path: Path) -> str:
hasher = hashlib.sha256()
with path.open('rb') as fh:
for chunk in iter(lambda: fh.read(1024 * 1024), b''):
hasher.update(chunk)
return hasher.hexdigest()


def _ensure_bundled_python() -> Path:
# Keep defaults aligned with build-exe.bat.
python_version = os.environ.get('CLIPABIT_BUNDLED_PYTHON_VERSION', '3.11.15')
python_build_tag = os.environ.get('CLIPABIT_BUNDLED_PYTHON_BUILD_TAG', '20260303')
expected_sha = os.environ.get(
'CLIPABIT_BUNDLED_PYTHON_SHA256',
'6f194e1ede02260fd3d758893bbf1d3bb4084652d436a8300a229da721c3ddf8',
)

# Honor explicit cache dir if set; otherwise use build/python so spec can run standalone.
cache_base = Path(os.environ.get('PYTHON_CACHE_DIR', str(BUILD_DIR / 'python')))
python_root = cache_base / 'python'
python_exe = python_root / 'python.exe'
if python_exe.exists():
return python_root

cache_base.mkdir(parents=True, exist_ok=True)
archive_name = f'cpython-{python_version}+{python_build_tag}-x86_64-pc-windows-msvc-install_only.tar.gz'
archive_path = cache_base / archive_name
url = (
'https://github.com/astral-sh/python-build-standalone/releases/download/'
f'{python_build_tag}/{archive_name}'
)

print(f'[spec] Downloading bundled Python from {url}...')
urllib.request.urlretrieve(url, archive_path)

actual_sha = _sha256_file(archive_path)
if actual_sha.lower() != expected_sha.lower():
archive_path.unlink(missing_ok=True)
raise RuntimeError(
'Bundled Python checksum mismatch: '
f'expected {expected_sha}, got {actual_sha}'
)

# Remove stale extraction before unpacking.
shutil.rmtree(python_root, ignore_errors=True)
with tarfile.open(archive_path, 'r:gz') as tf:
tf.extractall(cache_base)
archive_path.unlink(missing_ok=True)

if not python_exe.exists():
raise RuntimeError(f'Bundled Python extraction failed; missing {python_exe}')
return python_root


def _ensure_release_json() -> Path:
release_path = ROOT / 'release.json'
if release_path.exists():
return release_path

payload = {
'tag': os.environ.get('CLIPABIT_PLUGIN_RELEASE', 'local-build'),
'environment': os.environ.get('CLIPABIT_ENVIRONMENT', 'prod'),
}
release_path.write_text(json.dumps(payload), encoding='utf-8')
return release_path


PLUGIN_DIR = _resolve_plugin_dir()
BUNDLED_PYTHON_DIR = _ensure_bundled_python()
RELEASE_JSON = _ensure_release_json()

a = Analysis(
['installer-script.py'],
pathex=[],
binaries=[],
datas=[
('plugin', 'plugin'),
# Get python from env var or fallback
(os.path.join(os.environ.get('PYTHON_CACHE_DIR', 'build/python'), 'python'), 'python'),
('release.json', '.'),
(str(PLUGIN_DIR), 'plugin'),
(str(BUNDLED_PYTHON_DIR), 'python'),
(str(RELEASE_JSON), '.'),
],
hiddenimports=['tomllib'],
hookspath=[],
Expand Down
Loading