# Multi-platform build + packaging workflow # - Builds a pyinstaller executable on Windows, Debian, Arch, Rocky. # - Packages using fpm into .deb, .rpm, and pacman (.pkg.tar.zst) files with explicit filenames. # - Creates a GitHub Release with the produced artifacts. # # Notes: # - Poetry is installed via snok/install-poetry@v1 in all jobs. # - fpm gem is pinned to 1.16.0 in the examples; change as needed. # - Rocky job uses tarball downloads for pyenv and python-build (non-interactive, CI-friendly). name: Build Multi-Platform Binaries on: workflow_dispatch: permissions: contents: write env: # change this if you prefer a different pinned fpm version FPM_VERSION: "1.16.0" # Personal Access Token for HTTPS git operations (populate in repository secrets) CI_CD_PAT: ${{ secrets.CI_CD_PAT }} jobs: build-windows: runs-on: windows-latest steps: - name: Checkout code uses: actions/checkout@v4 - name: Set up Python 3.12 uses: actions/setup-python@v5 with: python-version: '3.12' - name: Install Poetry uses: snok/install-poetry@v1 with: version: latest virtualenvs-create: true virtualenvs-in-project: true - name: Ensure Poetry is on PATH (Windows) shell: pwsh run: | # Add Poetry user bin to PATH for subsequent steps in this job $poetryPath = Join-Path $env:USERPROFILE ".local\bin" Write-Output $poetryPath >> $Env:GITHUB_PATH - name: Install dependencies run: | poetry install - name: Build Windows executable run: | poetry run pyinstaller --onefile --windowed --icon=icon_media/robot_files_256.ico --name android-file-handler src/main.py - name: Upload Windows artifact uses: actions/upload-artifact@v4 with: name: windows-binary path: dist/android-file-handler.exe build-debian: runs-on: ubuntu-latest container: image: python:3.12-slim steps: - name: Install system dependencies & gem fpm run: | set -euo pipefail apt-get update apt-get install -y --no-install-recommends curl git build-essential ruby ruby-dev gcc make zlib1g-dev ca-certificates # install pinned fpm to the system gem dir (will be available under gem env's EXECUTABLE DIRECTORY or /usr/local/bin) gem install --no-document -v "${FPM_VERSION}" fpm - name: Checkout code uses: actions/checkout@v4 - name: Set up Python 3.12 uses: actions/setup-python@v5 with: python-version: '3.12' - name: Install Poetry uses: snok/install-poetry@v1 with: version: latest virtualenvs-create: true virtualenvs-in-project: true - name: Configure Poetry run: | echo 'export PATH="$HOME/.local/bin:$PATH"' >> $GITHUB_ENV export PATH="$HOME/.local/bin:$PATH" poetry config virtualenvs.create true poetry config virtualenvs.in-project true - name: Install dependencies & build executable run: | export PATH="$HOME/.local/bin:$PATH" poetry env use python3.12 || true poetry install poetry run pyinstaller --onefile --icon=icon_media/robot_files_256.png --name android-file-handler src/main.py - name: Package .deb (fpm) run: | set -euo pipefail export PATH="$HOME/.local/bin:$PATH" VERSION="$(poetry version -s)" mkdir -p dist chmod +x dist/android-file-handler # explicit filename into dist/ fpm -s dir -t deb -n android-file-handler -v "$VERSION" --architecture amd64 --prefix /usr/local/bin -p "dist/android-file-handler_${VERSION}_amd64.deb" dist/android-file-handler - name: Upload Debian .deb uses: actions/upload-artifact@v4 with: name: debian-package path: dist/android-file-handler_*.deb build-arch: runs-on: ubuntu-latest container: image: archlinux:latest steps: - name: Install system dependencies (Arch) and system Ruby run: | set -euo pipefail pacman -Syu --noconfirm pacman -S --noconfirm ruby base-devel curl git tar ca-certificates # Install fpm system-wide and pin version so fpm will be in /usr/local/bin gem install --no-document erb gem install --no-document -v "${FPM_VERSION}" fpm --bindir /usr/local/bin # persist system bindir to subsequent steps (usually already on PATH) echo "/usr/local/bin" >> $GITHUB_PATH - name: Checkout code uses: actions/checkout@v4 - name: Set up Python 3.12 uses: actions/setup-python@v5 with: python-version: '3.12' - name: Install Poetry uses: snok/install-poetry@v1 with: version: latest virtualenvs-create: true virtualenvs-in-project: true - name: Configure Poetry run: | echo 'export PATH="$HOME/.local/bin:$PATH"' >> $GITHUB_ENV export PATH="$HOME/.local/bin:$PATH" - name: Install dependencies & build executable run: | export PATH="$HOME/.local/bin:/usr/local/bin:$PATH" poetry install poetry run pyinstaller --onefile --icon=icon_media/robot_files_256.png --name android-file-handler src/main.py - name: Package pacman (fpm) run: | set -euo pipefail export PATH="$HOME/.local/bin:/usr/local/bin:$PATH" VERSION="$(poetry version -s)" mkdir -p dist chmod +x dist/android-file-handler # explicit filename into dist/ fpm -s dir -t pacman -n android-file-handler -v "$VERSION" --architecture x86_64 --prefix /usr/local/bin -p "dist/android-file-handler-${VERSION}-1-x86_64.pkg.tar.zst" dist/android-file-handler - name: Upload Arch package uses: actions/upload-artifact@v4 with: name: arch-package path: dist/*.pkg.tar.* build-rocky: runs-on: ubuntu-latest container: image: rockylinux:9 steps: - name: Robust dnf setup and install build deps (retry-clean-refresh) run: | set -euo pipefail retry() { local max_tries=5 local sleep_base=3 local n=1 until "$@"; do if [ $n -ge $max_tries ]; then echo "Command failed after $n attempts: $*" return 1 fi echo "Command failed: $*. Retrying ($n/$max_tries) after $((sleep_base * n))s..." sleep $((sleep_base * n)) n=$((n + 1)) done return 0 } # Ensure plugin and enable CRB retry dnf -y install dnf-plugins-core retry dnf config-manager --set-enabled crb || true # Clean caches and refresh metadata retry dnf -y clean all rm -rf /var/cache/dnf || true retry dnf -y makecache --refresh # Install build-time deps including ruby so gem stdlib is present retry dnf -y install -y \ git gcc make openssl-devel bzip2-devel libffi-devel zlib-devel xz-devel \ readline-devel sqlite-devel tk-devel gdbm-devel libuuid-devel patch \ ruby ruby-devel rubygems rpm-build tar ca-certificates - name: Tarball-based pyenv + python-build (non-interactive) id: pyenv_tarball run: | set -euo pipefail retry() { local max=5 n=1 until "$@"; do if [ $n -ge $max ]; then echo "Command failed after $n attempts: $*" return 1 fi echo "Command failed: $*. Retrying ($n/$max) after $((n * 2))s..." sleep $((n * 2)) n=$((n + 1)) done return 0 } echo "Ensure curl/tar/ca-certificates present (dnf already installed them above)" # Place pyenv inside workspace export PYENV_ROOT="${GITHUB_WORKSPACE}/.pyenv" mkdir -p "$PYENV_ROOT" mkdir -p "$PYENV_ROOT/plugins" # Pin refs (replace master with tags/commits for reproducible runs) PYENV_PYENV_REF="${PYENV_PYENV_REF:-master}" PYENV_BUILD_REF="${PYENV_BUILD_REF:-master}" echo "Cloning pyenv (${PYENV_PYENV_REF}) and python-build (${PYENV_BUILD_REF}) via git (shallow)" # Use HTTPS clones and a repository PAT when available REPO_PYENV="https://github.com/pyenv/pyenv.git" REPO_BUILD="https://github.com/pyenv/pyenv-build.git" EXTRA_HEADER="" if [ -n "${CI_CD_PAT:-}" ]; then echo "Configuring git to use CI_CD_PAT for HTTPS operations" # Create Authorization header: Basic base64(:PAT) EXTRA_HEADER="Authorization: Basic $(printf ':%s' "${CI_CD_PAT}" | base64)" # set globally so git (and subprocesses) honor it for the duration of the job git config --global http.extraHeader "${EXTRA_HEADER}" # Prevent git from trying to prompt for credentials interactively export GIT_TERMINAL_PROMPT=0 fi gitx() { # http.extraHeader is configured globally when CI_CD_PAT exists; just run git git "$@" } # Masked diagnostic: confirm whether CI_CD_PAT/EXTRA_HEADER is present (do NOT print secrets) if [ -n "${CI_CD_PAT:-}" ]; then echo "DEBUG: CI_CD_PAT is present -> git operations will use http.extraHeader (masked)" else echo "DEBUG: CI_CD_PAT is NOT present -> git operations will run unauthenticated" fi # pyenv: shallow clone the requested ref if possible, otherwise fall back to default shallow clone if [ -d "$PYENV_ROOT/.git" ]; then retry gitx -C "$PYENV_ROOT" fetch --all --prune --depth=1 || true retry gitx -C "$PYENV_ROOT" reset --hard "origin/${PYENV_PYENV_REF}" || true elif [ -d "$PYENV_ROOT" ] && [ -z "$(ls -A "$PYENV_ROOT")" ]; then # directory exists but empty -> safe to clone into if ! retry gitx clone --depth 1 --branch "${PYENV_PYENV_REF}" "$REPO_PYENV" "$PYENV_ROOT"; then echo "git clone failed; attempting GitHub API tarball fallback using PAT" if [ -n "${CI_CD_PAT:-}" ]; then retry curl -fSL -H "Authorization: token ${CI_CD_PAT}" "https://api.github.com/repos/pyenv/pyenv/tarball/${PYENV_PYENV_REF}" | tar -xz --strip-components=1 -C "$PYENV_ROOT" else echo "No CI_CD_PAT provided; cannot use tarball fallback" exit 1 fi fi else # directory exists and is not a git repo (or contains leftover files) -> move aside then clone if [ -d "$PYENV_ROOT" ]; then backup_dest="${PYENV_ROOT}.bak.$(date +%s)" echo "Found existing non-git pyenv directory; moving aside to ${backup_dest}" mv "$PYENV_ROOT" "$backup_dest" fi retry gitx clone --depth 1 --branch "${PYENV_PYENV_REF}" "$REPO_PYENV" "$PYENV_ROOT" || \ { echo "Branch/ref ${PYENV_PYENV_REF} not available for shallow clone; falling back to default branch"; retry gitx clone --depth 1 "$REPO_PYENV" "$PYENV_ROOT"; } fi # python-build plugin: place under plugins/python-build mkdir -p "$PYENV_ROOT/plugins" if [ -d "$PYENV_ROOT/plugins/python-build/.git" ]; then retry gitx -C "$PYENV_ROOT/plugins/python-build" fetch --all --prune --depth=1 || true retry gitx -C "$PYENV_ROOT/plugins/python-build" reset --hard "origin/${PYENV_BUILD_REF}" || true elif [ -d "$PYENV_ROOT/plugins/python-build" ] && [ -z "$(ls -A "$PYENV_ROOT/plugins/python-build")" ]; then if ! retry gitx clone --depth 1 --branch "${PYENV_BUILD_REF}" "$REPO_BUILD" "$PYENV_ROOT/plugins/python-build"; then echo "git clone for python-build failed; attempting GitHub API tarball fallback using PAT" if [ -n "${CI_CD_PAT:-}" ]; then retry curl -fSL -H "Authorization: token ${CI_CD_PAT}" "https://api.github.com/repos/pyenv/pyenv-build/tarball/${PYENV_BUILD_REF}" | tar -xz --strip-components=1 -C "$PYENV_ROOT/plugins/python-build" else echo "No CI_CD_PAT provided; cannot use tarball fallback" exit 1 fi fi else if [ -d "$PYENV_ROOT/plugins/python-build" ]; then pb_backup="${PYENV_ROOT}/plugins/python-build.bak.$(date +%s)" echo "Found existing non-git python-build dir; moving aside to ${pb_backup}" mv "$PYENV_ROOT/plugins/python-build" "$pb_backup" fi retry gitx clone --depth 1 --branch "${PYENV_BUILD_REF}" "$REPO_BUILD" "$PYENV_ROOT/plugins/python-build" || \ { echo "Branch/ref ${PYENV_BUILD_REF} not available for shallow clone; falling back to default branch"; retry gitx clone --depth 1 "$REPO_BUILD" "$PYENV_ROOT/plugins/python-build"; } fi # Initialize pyenv environment for this job export PATH="$PYENV_ROOT/bin:$PATH" eval "$($PYENV_ROOT/bin/pyenv init -)" # Persist environment variables for subsequent steps echo "PYENV_ROOT=$PYENV_ROOT" >> $GITHUB_ENV echo "PATH=$PYENV_ROOT/shims:$PYENV_ROOT/bin:$PATH" >> $GITHUB_ENV echo "pyenv version:" "$PYENV_ROOT/bin/pyenv" --version || true echo "python-build plugin at: $PYENV_ROOT/plugins/python-build" ls -la "$PYENV_ROOT/plugins/python-build" || true - name: Build Python with pyenv (3.12.11) and set global env: PYENV_ROOT: ${{ github.workspace }}/.pyenv run: | set -euo pipefail export PYENV_ROOT="${PYENV_ROOT}" export PATH="$PYENV_ROOT/bin:$PATH" eval "$($PYENV_ROOT/bin/pyenv init -)" # Install python (idempotent) $PYENV_ROOT/bin/pyenv install -s 3.12.11 $PYENV_ROOT/bin/pyenv global 3.12.11 python --version pip --version - name: Install Poetry uses: snok/install-poetry@v1 with: version: latest virtualenvs-create: true virtualenvs-in-project: true - name: Configure Poetry run: | echo 'export PATH="$HOME/.local/bin:$PATH"' >> $GITHUB_ENV export PATH="$HOME/.local/bin:$PATH" poetry config virtualenvs.create true poetry config virtualenvs.in-project true - name: Install dependencies & build executable run: | set -euo pipefail export PATH="$HOME/.local/bin:$PYENV_ROOT/shims:$PYENV_ROOT/bin:$PATH" poetry env use 3.12.11 || true poetry install poetry run pyinstaller --onefile --icon=icon_media/robot_files_256.png --name android-file-handler src/main.py - name: Install fpm and package .rpm run: | set -euo pipefail # install pinned fpm and ensure binary is in system bindir gem install --no-document -v "${FPM_VERSION}" fpm --bindir /usr/local/bin echo "/usr/local/bin" >> $GITHUB_PATH # Build rpm with explicit filename VERSION="$(poetry version -s)" mkdir -p dist chmod +x dist/android-file-handler fpm -s dir -t rpm -n android-file-handler -v "$VERSION" --architecture x86_64 --prefix /usr/local/bin -p "dist/android-file-handler-${VERSION}-1.x86_64.rpm" dist/android-file-handler - name: Upload Rocky RPM uses: actions/upload-artifact@v4 with: name: rocky-package path: dist/*.rpm create-release: needs: [build-windows, build-debian, build-arch, build-rocky] runs-on: ubuntu-latest steps: - name: Download all artifacts uses: actions/download-artifact@v4 with: merge-multiple: true path: ./binaries - name: List downloaded binaries (debug) run: | echo "Downloaded files:" ls -la ./binaries || true - name: Create Release uses: softprops/action-gh-release@v1 with: files: | ./binaries/android-file-handler.exe ./binaries/android-file-handler_*.deb ./binaries/android-file-handler-*.rpm ./binaries/android-file-handler-*-x86_64.pkg.tar.* draft: false prerelease: false env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}