From 3a5d4e7dbd8f59732dd346f14eeb382626264462 Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Fri, 20 Mar 2026 19:30:22 -0500 Subject: [PATCH] initial refactor of ci-cd pipeline --- .github/workflows/release-prefect-dagger.yml | 295 ++++++++++++++++ .github/workflows/release.yml | 56 ++- CLAUDE.md | 70 +++- ci/__init__.py | 1 + ci/config.py | 74 ++++ ci/dagger_pipeline.py | 136 ++++++++ ci/prefect_flow.py | 330 ++++++++++++++++++ ci/signing.py | 90 +++++ docker-compose.yml | 17 +- podman-compose.yml | 72 ++++ pyproject.toml | 7 + scripts/build_package_linux.py | 27 +- scripts/docker/README.md | 49 +-- scripts/rhel_postinst.sh | 2 +- .../android-file-handler-windows.spec | 3 +- .../windows/android-file-handler-setup.iss | 56 +++ scripts/windows/first_run_install.ps1 | 51 --- src/gui/dialogs/license_agreement.py | 45 --- src/main.py | 3 - 19 files changed, 1216 insertions(+), 168 deletions(-) create mode 100644 .github/workflows/release-prefect-dagger.yml create mode 100644 ci/__init__.py create mode 100644 ci/config.py create mode 100644 ci/dagger_pipeline.py create mode 100644 ci/prefect_flow.py create mode 100644 ci/signing.py create mode 100644 podman-compose.yml create mode 100644 scripts/windows/android-file-handler-setup.iss delete mode 100644 scripts/windows/first_run_install.ps1 diff --git a/.github/workflows/release-prefect-dagger.yml b/.github/workflows/release-prefect-dagger.yml new file mode 100644 index 0000000..3702bbb --- /dev/null +++ b/.github/workflows/release-prefect-dagger.yml @@ -0,0 +1,295 @@ +# Multi-platform build + packaging workflow +# Thin GitHub Actions wrapper around Prefect + Dagger pipeline. +# +# Architecture: +# - Windows build runs natively on windows-latest (cannot containerize) +# - Linux builds run via Dagger containers orchestrated by Prefect +# - Signing, release creation, and S3 upload handled by Prefect tasks +# - Container runtime: Podman (Dagger connects via Podman socket) +# +# Local equivalent: +# poetry run python -m ci.prefect_flow full \ +# --gpg-passphrase "$GPG_PASSPHRASE" \ +# --github-token "$GITHUB_TOKEN" +name: Build Multi-Platform Binaries + +on: + workflow_dispatch: + +permissions: + contents: read + packages: read + +concurrency: + group: release-workflow + cancel-in-progress: true + +env: + CI_CD: true + CI_CD_PAT: ${{ secrets.CI_CD_PAT }} + +jobs: + + # ── Windows Build (native runner — cannot containerize) ────────────── + build-windows: + runs-on: windows-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + ref: main + + - name: Set up Python 3.13 + uses: actions/setup-python@v5 + with: + python-version: '3.13' + + - name: Install Poetry + uses: snok/install-poetry@v1 + with: + version: latest + virtualenvs-create: true + virtualenvs-in-project: true + + - name: Ensure Poetry is on PATH + shell: pwsh + run: | + $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 scripts/spec_scripts/android-file-handler-windows.spec + + - name: Build Windows installer (Inno Setup) + shell: pwsh + run: | + $version = (poetry version -s).Trim() + Write-Output "Building installer for version $version" + & "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" ` + "scripts\windows\android-file-handler-setup.iss" ` + "/DMyAppVersion=$version" + + - name: Import GPG key + shell: pwsh + run: | + $env:GPG_TTY = "not a tty" + echo "${{ secrets.GPG_PRIVATE_KEY }}" | gpg --batch --import + gpg --list-secret-keys + + - name: Sign and hash Windows artifacts + shell: pwsh + run: | + $passphraseFile = New-TemporaryFile + try { + "${{ secrets.GPG_PASSPHRASE }}" | Out-File -FilePath $passphraseFile -Encoding ASCII -NoNewline + + # Sign and hash standalone executable + $exePath = Get-ChildItem -Path dist -Filter "android-file-handler-windows.exe" | + Select-Object -First 1 -ExpandProperty FullName + if (-not $exePath) { Write-Error "Standalone executable not found"; exit 1 } + gpg --batch --yes --passphrase-file "$passphraseFile" --detach-sign --armor "$exePath" + $hash = (Get-FileHash -Path "$exePath" -Algorithm SHA256).Hash.ToLower() + "$hash $(Split-Path -Leaf $exePath)" | + Out-File -FilePath "dist/android-file-handler-windows.sha256" -Encoding ASCII -NoNewline + + # Sign and hash installer + $setupPath = Get-ChildItem -Path dist -Filter "android-file-handler-setup.exe" | + Select-Object -First 1 -ExpandProperty FullName + if (-not $setupPath) { Write-Error "Installer not found"; exit 1 } + gpg --batch --yes --passphrase-file "$passphraseFile" --detach-sign --armor "$setupPath" + $setupHash = (Get-FileHash -Path "$setupPath" -Algorithm SHA256).Hash.ToLower() + "$setupHash $(Split-Path -Leaf $setupPath)" | + Out-File -FilePath "dist/android-file-handler-setup.sha256" -Encoding ASCII -NoNewline + } + finally { + if (Test-Path $passphraseFile) { Remove-Item $passphraseFile -Force } + } + + - name: Upload Windows artifact + uses: actions/upload-artifact@v4 + with: + name: windows-binary + path: | + dist/android-file-handler-windows.exe + dist/android-file-handler-windows.exe.asc + dist/android-file-handler-windows.sha256 + dist/android-file-handler-setup.exe + dist/android-file-handler-setup.exe.asc + dist/android-file-handler-setup.sha256 + + # ── Linux Builds (Prefect + Dagger with Podman backend) ───────────── + build-linux: + runs-on: ubuntu-latest + permissions: + contents: read + packages: read + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + ref: main + + - name: Set up Python 3.13 + uses: actions/setup-python@v5 + with: + python-version: '3.13' + + - name: Install Poetry + uses: snok/install-poetry@v1 + with: + version: latest + virtualenvs-create: true + virtualenvs-in-project: true + + - name: Install project + CI dependencies + run: poetry install --with ci + + - name: Set up Podman + run: | + sudo apt-get update -qq + sudo apt-get install -y -qq podman + # Start rootful Podman socket for Dagger compatibility + sudo systemctl enable --now podman.socket + echo "DOCKER_HOST=unix:///run/podman/podman.sock" >> "$GITHUB_ENV" + + - name: Install Dagger CLI + uses: dagger/dagger-for-github@v7 + with: + verb: version + + - name: Log in to GHCR (Podman) + run: | + echo "${{ secrets.GITHUB_TOKEN }}" | + podman login ghcr.io -u "${{ github.actor }}" --password-stdin + + - name: Build all Linux distros (Prefect + Dagger) + run: poetry run python -m ci.prefect_flow build-linux + + - name: Import GPG key + run: | + echo "${{ secrets.GPG_PRIVATE_KEY }}" | gpg --batch --import + + - name: Sign Linux artifacts + run: | + poetry run python -m ci.prefect_flow sign \ + --gpg-passphrase "${{ secrets.GPG_PASSPHRASE }}" + + - name: Upload Debian package + uses: actions/upload-artifact@v4 + with: + name: debian-package + path: | + dist/android-file-handler_*.deb + dist/android-file-handler_*.deb.asc + dist/android-file-handler-debian.sha256 + pkg_dist_debian/** + + - name: Upload Arch package + uses: actions/upload-artifact@v4 + with: + name: arch-package + path: | + dist/*.pkg.tar.* + dist/android-file-handler-arch.sha256 + pkg_dist_arch/** + + - name: Upload RHEL package + uses: actions/upload-artifact@v4 + with: + name: rhel-package + path: | + dist/*.rpm + dist/*.rpm.asc + dist/android-file-handler-rhel.sha256 + pkg_dist_rhel/** + + # ── Release + S3 Upload (Prefect) ─────────────────────────────────── + do-release: + needs: [build-windows, build-linux] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + ref: main + + - name: Set up Python 3.13 + uses: actions/setup-python@v5 + with: + python-version: '3.13' + + - name: Install Poetry + uses: snok/install-poetry@v1 + with: + version: latest + virtualenvs-create: true + virtualenvs-in-project: true + + - name: Install project + CI dependencies + run: poetry install --with ci + + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + merge-multiple: true + path: ./dist + + - name: Create GitHub release (Prefect) + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + poetry run python -m ci.prefect_flow release \ + --github-token "$GITHUB_TOKEN" + + upload-s3: + runs-on: ubuntu-latest + needs: do-release + if: needs.do-release.result == 'success' + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + ref: main + + - name: Install AWS CLI + run: python -m pip install --upgrade pip awscli + + - name: Download build artifacts + uses: actions/download-artifact@v4 + with: + merge-multiple: true + path: ./binaries + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v2 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ secrets.AWS_REGION }} + + - name: Upload artifacts to S3 + run: | + set -euo pipefail + if [ -z "${{ secrets.S3_BUCKET }}" ]; then + echo "S3_BUCKET secret not set; skipping upload" + exit 0 + fi + aws s3 sync ./binaries s3://${{ secrets.S3_BUCKET }}/builds/${{ github.run_id }}/ --acl private + env: + AWS_PAGER: "" + + sync-wiki: + needs: do-release + if: needs.do-release.result == 'success' + permissions: + contents: write + pull-requests: write + uses: ./.github/workflows/sync-wiki.yml + with: + branch: main + secrets: inherit diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 451137c..2ae75a2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -68,6 +68,15 @@ jobs: # Use the Windows spec file so packaging is consistent and reproducible poetry run pyinstaller scripts/spec_scripts/android-file-handler-windows.spec + - name: Build Windows installer (Inno Setup) + shell: pwsh + run: | + $version = (poetry version -s).Trim() + Write-Output "Building installer for version $version" + & "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" ` + "scripts\windows\android-file-handler-setup.iss" ` + "/DMyAppVersion=$version" + - name: Import GPG key shell: pwsh run: | @@ -75,23 +84,37 @@ jobs: echo "${{ secrets.GPG_PRIVATE_KEY }}" | gpg --batch --import gpg --list-secret-keys - - name: Sign and hash Windows executable + - name: Sign and hash Windows artifacts shell: pwsh run: | - $exePath = Get-ChildItem -Path dist -Filter "android-file-handler.exe" -Recurse | Select-Object -First 1 -ExpandProperty FullName - if (-not $exePath) { - Write-Error "Executable not found" - exit 1 - } - Write-Output "Found executable: $exePath" - # Create temporary file for passphrase $passphraseFile = New-TemporaryFile try { "${{ secrets.GPG_PASSPHRASE }}" | Out-File -FilePath $passphraseFile -Encoding ASCII -NoNewline - # Sign with GPG using passphrase file + # Sign and hash the standalone executable + $exePath = Get-ChildItem -Path dist -Filter "android-file-handler-windows.exe" | Select-Object -First 1 -ExpandProperty FullName + if (-not $exePath) { + Write-Error "Standalone executable not found" + exit 1 + } + Write-Output "Signing executable: $exePath" gpg --batch --yes --passphrase-file "$passphraseFile" --detach-sign --armor "$exePath" + $hash = (Get-FileHash -Path "$exePath" -Algorithm SHA256).Hash.ToLower() + "$hash $(Split-Path -Leaf $exePath)" | Out-File -FilePath "dist/android-file-handler-windows.sha256" -Encoding ASCII -NoNewline + Write-Output "Executable SHA-256: $hash" + + # Sign and hash the Inno Setup installer + $setupPath = Get-ChildItem -Path dist -Filter "android-file-handler-setup.exe" | Select-Object -First 1 -ExpandProperty FullName + if (-not $setupPath) { + Write-Error "Installer not found" + exit 1 + } + Write-Output "Signing installer: $setupPath" + gpg --batch --yes --passphrase-file "$passphraseFile" --detach-sign --armor "$setupPath" + $setupHash = (Get-FileHash -Path "$setupPath" -Algorithm SHA256).Hash.ToLower() + "$setupHash $(Split-Path -Leaf $setupPath)" | Out-File -FilePath "dist/android-file-handler-setup.sha256" -Encoding ASCII -NoNewline + Write-Output "Installer SHA-256: $setupHash" } finally { # Clean up passphrase file @@ -100,22 +123,17 @@ jobs: } } - # Generate SHA-256 hash - $hash = (Get-FileHash -Path "$exePath" -Algorithm SHA256).Hash.ToLower() - $hashFile = "dist/android-file-handler-windows.sha256" - "$hash $(Split-Path -Leaf $exePath)" | Out-File -FilePath $hashFile -Encoding ASCII -NoNewline - Write-Output "SHA-256: $hash" - - name: Upload Windows artifact uses: actions/upload-artifact@v4 with: name: windows-binary path: | - dist/**/android-file-handler*.exe - dist/**/android-file-handler*.exe.asc - dist/android-file-handler.exe - dist/android-file-handler.exe.asc + dist/android-file-handler-windows.exe + dist/android-file-handler-windows.exe.asc dist/android-file-handler-windows.sha256 + dist/android-file-handler-setup.exe + dist/android-file-handler-setup.exe.asc + dist/android-file-handler-setup.sha256 build-debian: permissions: diff --git a/CLAUDE.md b/CLAUDE.md index 3553978..dc2784f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,21 +58,35 @@ poetry run mypy src/ poetry run python scripts/build_package_linux.py ``` -#### Docker Compose Build (Recommended for Linux) +#### Prefect + Dagger Build (CI Pipeline Locally) ```sh -# Build all distributions (Debian, Arch, RHEL) -docker compose up --build +# Install CI dependencies +poetry install --with ci + +# Build all Linux distros via Dagger containers +poetry run python -m ci.prefect_flow build-linux + +# Sign artifacts +poetry run python -m ci.prefect_flow sign --gpg-passphrase "$GPG_PASSPHRASE" + +# Full pipeline (build + sign + release) +poetry run python -m ci.prefect_flow full \ + --gpg-passphrase "$GPG_PASSPHRASE" \ + --github-token "$GITHUB_TOKEN" +``` + +#### Podman Compose Build (Recommended for Linux) +```sh +# Build all distributions +podman-compose up --build # Build specific distribution -docker compose up --build debian -docker compose up --build arch -docker compose up --build rhel - -# Build all in parallel -docker compose up --build --parallel +podman-compose up --build debian +podman-compose up --build arch +podman-compose up --build rhel # Clean build artifacts -docker compose down -v && rm -rf dist pkg_dist_* dist_* +podman-compose down -v && rm -rf dist pkg_dist_* dist_* ``` See [scripts/docker/README.md](scripts/docker/README.md) for detailed Docker build documentation. @@ -82,6 +96,10 @@ See [scripts/docker/README.md](scripts/docker/README.md) for detailed Docker bui # Windows executable (PyInstaller) poetry run pyinstaller scripts/spec_scripts/android-file-handler-windows.spec +# Windows installer (Inno Setup, after PyInstaller build) +# Inno Setup 6 is pre-installed on GitHub Actions windows-latest runners +& "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" scripts\windows\android-file-handler-setup.iss /DMyAppVersion=0.1.1 + # Linux packages use distro-specific spec files: # - android-file-handler-debian.spec # - android-file-handler-arch.spec @@ -116,6 +134,13 @@ poetry run pyinstaller scripts/spec_scripts/android-file-handler-windows.spec - **scripts/**: Build and packaging scripts - `build_package_linux.py`: Unified Linux packaging script (uses DISTRO_TYPE env var) - `spec_scripts/`: PyInstaller spec files for each platform + - `windows/android-file-handler-setup.iss`: Inno Setup installer configuration + +- **ci/**: CI/CD pipeline orchestration + - `config.py`: Shared build configuration (distro configs, image references) + - `dagger_pipeline.py`: Dagger container build definitions for Linux + - `prefect_flow.py`: Prefect flow orchestration and CLI entry point + - `signing.py`: GPG signing and SHA-256 hashing utilities - **tests/**: Test suite mirroring src/ structure @@ -135,12 +160,25 @@ poetry run pyinstaller scripts/spec_scripts/android-file-handler-windows.spec ## CI/CD -The project uses GitHub Actions for multi-platform builds (`.github/workflows/release.yml`): -- Runs tests on Linux and Windows -- Builds binaries for Windows, Debian, Arch, and RHEL -- Packages using PyInstaller + fpm -- Supports manual workflow dispatch with configurable jobs -- Optional GitHub release creation and S3 upload +The project uses a **Prefect + Dagger** pipeline wrapped by GitHub Actions (`.github/workflows/release-prefect-dagger.yml`): + +- **Dagger** runs containerized Linux builds (Debian, Arch, RHEL) using pre-built builder images +- **Prefect** orchestrates the pipeline: build → sign → release → S3 upload +- **GitHub Actions** provides the runner infrastructure and Windows build (cannot containerize) +- **Podman** is the container runtime (Dagger connects via Podman socket) + +Pipeline structure: +1. `build-windows` — Native Windows build on `windows-latest` +2. `build-linux` — All Linux distros built in parallel via Prefect + Dagger +3. `do-release` — Creates GitHub release with all artifacts +4. `upload-s3` — Optional S3 upload +5. `sync-wiki` — Wiki synchronization + +The CI pipeline modules live in `ci/`: +- `ci/config.py` — Shared build configuration +- `ci/dagger_pipeline.py` — Dagger container build definitions +- `ci/prefect_flow.py` — Prefect flow orchestration and CLI +- `ci/signing.py` — GPG signing and SHA-256 hashing utilities ## Coding Standards diff --git a/ci/__init__.py b/ci/__init__.py new file mode 100644 index 0000000..126aac1 --- /dev/null +++ b/ci/__init__.py @@ -0,0 +1 @@ +"""CI/CD pipeline orchestration using Prefect and Dagger.""" diff --git a/ci/config.py b/ci/config.py new file mode 100644 index 0000000..b6073a7 --- /dev/null +++ b/ci/config.py @@ -0,0 +1,74 @@ +"""Shared configuration for CI/CD pipeline.""" + +from dataclasses import dataclass, field +from pathlib import Path + + +@dataclass(frozen=True) +class DistroConfig: + """Configuration for a Linux distribution build.""" + + name: str + distro_type: str + container_image: str + bin_path: str + pkg_type: str + architecture: str + postinstall: str | None = None + + +@dataclass(frozen=True) +class PipelineConfig: + """Top-level pipeline configuration.""" + + fpm_version: str = "1.16.0" + project_root: Path = field(default_factory=lambda: Path(__file__).parent.parent.resolve()) + + # Container images (from GHCR) + debian_image: str = "ghcr.io/jmr-dev/android-file-handler-debian-builder:debian13-trixie" + arch_image: str = "ghcr.io/jmr-dev/android-file-handler-arch-builder:latest" + rhel_image: str = "ghcr.io/jmr-dev/android-file-handler-rhel-builder:fedora42" + + @property + def distros(self) -> list[DistroConfig]: + """Return all Linux distribution build configurations.""" + return [ + DistroConfig( + name="Debian", + distro_type="debian", + container_image=self.debian_image, + bin_path="usr/local/bin", + pkg_type="deb", + architecture="amd64", + postinstall="scripts/debian_postinst.sh", + ), + DistroConfig( + name="Arch", + distro_type="arch", + container_image=self.arch_image, + bin_path="usr/bin", + pkg_type="pacman", + architecture="x86_64", + postinstall=None, + ), + DistroConfig( + name="RHEL", + distro_type="rhel", + container_image=self.rhel_image, + bin_path="usr/bin", + pkg_type="rpm", + architecture="x86_64", + postinstall="scripts/rhel_postinst.sh", + ), + ] + + # Artifact patterns for each distro + artifact_patterns: dict[str, list[str]] = field(default_factory=lambda: { + "debian": ["dist/android-file-handler_*.deb"], + "arch": ["dist/android-file-handler-*.pkg.tar.zst"], + "rhel": ["dist/android-file-handler-*.rpm"], + "windows": [ + "dist/android-file-handler-windows.exe", + "dist/android-file-handler-setup.exe", + ], + }) diff --git a/ci/dagger_pipeline.py b/ci/dagger_pipeline.py new file mode 100644 index 0000000..eb7aec0 --- /dev/null +++ b/ci/dagger_pipeline.py @@ -0,0 +1,136 @@ +"""Dagger pipeline for building Linux distribution packages. + +Uses the Dagger Python SDK to run containerized builds for each +Linux distribution (Debian, Arch, RHEL) using pre-built builder images. +""" +# pyright: reportUnknownMemberType=false +# pyright: reportUnknownVariableType=false +# pyright: reportUnknownArgumentType=false +# pyright: reportUnknownParameterType=false + +import asyncio +import sys +from pathlib import Path + +import dagger # type: ignore[import-not-found] + +from ci.config import DistroConfig, PipelineConfig + + +async def build_linux_distro( + client: dagger.Client, + config: PipelineConfig, + distro: DistroConfig, +) -> dict[str, Path]: + """Build a single Linux distribution package inside a Dagger container. + + Args: + client: Active Dagger client connection. + config: Pipeline configuration. + distro: Distribution-specific build configuration. + + Returns: + Dictionary mapping artifact names to their local output paths. + """ + print(f"[dagger] Starting {distro.name} build using {distro.container_image}") + + # Mount the project source into the container, excluding local venv + source = client.host().directory( + str(config.project_root), + exclude=[".venv", "__pycache__", "dist", "dist_*", "pkg_dist_*", ".git"], + ) + + container = ( + client.container() + .from_(distro.container_image) + .with_directory("/workspace", source) + .with_workdir("/workspace") + .with_env_variable("CI_CD", "true") + .with_env_variable("DISTRO_TYPE", distro.distro_type) + .with_env_variable("FPM_VERSION", config.fpm_version) + .with_env_variable("POETRY_VIRTUALENVS_IN_PROJECT", "false") + .with_env_variable("POETRY_VIRTUALENVS_PATH", "/tmp/poetry-cache") + .with_exec(["poetry", "install", "--no-interaction"]) + .with_exec(["poetry", "run", "python", "scripts/build_package_linux.py"]) + ) + + # Export build artifacts back to host + dist_output = config.project_root / "dist" + pkg_dist_output = config.project_root / f"pkg_dist_{distro.distro_type}" + + await container.directory("/workspace/dist").export(str(dist_output)) + await container.directory(f"/workspace/pkg_dist_{distro.distro_type}").export( + str(pkg_dist_output) + ) + + print(f"[dagger] {distro.name} build complete — artifacts exported to {dist_output}") + + return { + "dist": dist_output, + "pkg_dist": pkg_dist_output, + } + + +async def build_all_linux(config: PipelineConfig | None = None) -> dict[str, dict[str, Path]]: + """Build all Linux distribution packages in parallel via Dagger. + + Args: + config: Pipeline configuration. Uses defaults if not provided. + + Returns: + Dictionary mapping distro names to their artifact paths. + """ + if config is None: + config = PipelineConfig() + + results: dict[str, dict[str, Path]] = {} + + async with dagger.Connection(dagger.Config(log_output=sys.stderr)) as client: + tasks = { + distro.distro_type: build_linux_distro(client, config, distro) + for distro in config.distros + } + + # Run all distro builds concurrently + completed = await asyncio.gather( + *tasks.values(), return_exceptions=True + ) + + for distro_type, result in zip(tasks.keys(), completed): + if isinstance(result, Exception): + print(f"[dagger] ERROR: {distro_type} build failed: {result}") + raise result + results[distro_type] = result # type: ignore[assignment] + + return results + + +async def build_single_linux( + distro_type: str, config: PipelineConfig | None = None +) -> dict[str, Path]: + """Build a single Linux distribution package. + + Args: + distro_type: One of 'debian', 'arch', 'rhel'. + config: Pipeline configuration. Uses defaults if not provided. + + Returns: + Dictionary of artifact paths for the built distro. + """ + if config is None: + config = PipelineConfig() + + distro = next( + (d for d in config.distros if d.distro_type == distro_type), None + ) + if distro is None: + raise ValueError( + f"Unknown distro type '{distro_type}'. Valid: debian, arch, rhel" + ) + + async with dagger.Connection(dagger.Config(log_output=sys.stderr)) as client: + return await build_linux_distro(client, config, distro) + + +if __name__ == "__main__": + asyncio.run(build_all_linux()) diff --git a/ci/prefect_flow.py b/ci/prefect_flow.py new file mode 100644 index 0000000..7e74a11 --- /dev/null +++ b/ci/prefect_flow.py @@ -0,0 +1,330 @@ +"""Prefect orchestration flow for the CI/CD release pipeline. + +Coordinates Dagger-based Linux builds, GPG signing, GitHub release +creation, and S3 artifact upload. + +Usage: + # Build all Linux distros (CI) + poetry run python -m ci.prefect_flow build-linux + + # Sign artifacts in dist/ + poetry run python -m ci.prefect_flow sign --gpg-passphrase "$GPG_PASSPHRASE" + + # Create GitHub release + upload S3 + poetry run python -m ci.prefect_flow release --github-token "$GITHUB_TOKEN" + + # Full pipeline (build + sign + release + S3) + poetry run python -m ci.prefect_flow full --gpg-passphrase "$GPG_PASSPHRASE" \\ + --github-token "$GITHUB_TOKEN" +""" + +import asyncio +import argparse +import os +import subprocess +import sys +from pathlib import Path + +from prefect import flow, task + +from ci.config import PipelineConfig +from ci.dagger_pipeline import build_all_linux +from ci.signing import sign_and_hash + + +# --------------------------------------------------------------------------- +# Tasks +# --------------------------------------------------------------------------- + + +@task(name="build-linux-distros", retries=1, retry_delay_seconds=30) +def task_build_linux(config: PipelineConfig) -> dict: + """Build all Linux distribution packages via Dagger containers.""" + print("=== Building Linux packages via Dagger ===") + results = asyncio.run(build_all_linux(config)) + print(f"Linux builds completed: {list(results.keys())}") + return results + + +@task(name="sign-artifacts") +def task_sign_artifacts( + dist_dir: Path, + gpg_passphrase: str, + patterns: list[str] | None = None, +) -> list[Path]: + """Sign and hash all release artifacts matching the given glob patterns. + + Args: + dist_dir: Directory containing artifacts. + gpg_passphrase: GPG key passphrase. + patterns: Glob patterns to match artifacts. Defaults to common package types. + + Returns: + List of generated signature and hash file paths. + """ + if patterns is None: + patterns = ["*.exe", "*.deb", "*.rpm", "*.pkg.tar.zst"] + + generated_files: list[Path] = [] + + for pattern in patterns: + for match in dist_dir.glob(pattern): + print(f"Signing: {match.name}") + sig_path, hash_path = sign_and_hash(match, gpg_passphrase) + generated_files.extend([sig_path, hash_path]) + + if not generated_files: + print(f"Warning: no artifacts matched patterns {patterns} in {dist_dir}") + + return generated_files + + +@task(name="get-version") +def task_get_version() -> str: + """Read the project version from pyproject.toml via Poetry.""" + result = subprocess.run( + ["poetry", "version", "-s"], + capture_output=True, + text=True, + check=True, + ) + version = result.stdout.strip() + if not version: + raise RuntimeError("Version is empty in pyproject.toml") + print(f"Project version: {version}") + return version + + +@task(name="prepare-release-files") +def task_prepare_release_files(dist_dir: Path, release_dir: Path) -> list[Path]: + """Collect all release artifacts into a single directory. + + Args: + dist_dir: Source directory containing built artifacts. + release_dir: Target directory for release files. + + Returns: + List of files copied into the release directory. + """ + release_dir.mkdir(parents=True, exist_ok=True) + + extensions = ["*.exe", "*.deb", "*.rpm", "*.pkg.tar.*", "*.asc", "*.sha256"] + copied: list[Path] = [] + + for ext in extensions: + for src in dist_dir.glob(ext): + dst = release_dir / src.name + if not dst.exists() or src.stat().st_mtime > dst.stat().st_mtime: + import shutil + shutil.copy2(src, dst) + copied.append(dst) + print(f" {src.name}") + + print(f"Prepared {len(copied)} release files in {release_dir}") + return copied + + +@task(name="create-github-release") +def task_create_github_release( + version: str, + release_dir: Path, + github_token: str, +) -> None: + """Create a GitHub release with artifacts using gh CLI. + + Args: + version: Semantic version string (e.g. '0.1.1'). + release_dir: Directory containing release files. + github_token: GitHub token for authentication. + """ + tag = f"v{version}" + files = list(release_dir.iterdir()) + + if not files: + raise RuntimeError(f"No files found in {release_dir}") + + env = {**os.environ, "GH_TOKEN": github_token} + + cmd = [ + "gh", "release", "create", tag, + "--title", f"Release {tag}", + "--latest", + ] + [str(f) for f in files] + + print(f"Creating GitHub release {tag} with {len(files)} files") + subprocess.run(cmd, check=True, env=env) + print(f"GitHub release {tag} created successfully") + + +@task(name="upload-s3") +def task_upload_s3( + release_dir: Path, + s3_bucket: str, + run_id: str, +) -> None: + """Upload release artifacts to S3. + + Args: + release_dir: Directory containing release files. + s3_bucket: S3 bucket name. + run_id: Unique identifier for this build run. + """ + if not s3_bucket: + print("S3_BUCKET not set; skipping upload") + return + + target = f"s3://{s3_bucket}/builds/{run_id}/" + print(f"Uploading to {target}") + + subprocess.run( + ["aws", "s3", "sync", str(release_dir), target, "--acl", "private"], + check=True, + env={**os.environ, "AWS_PAGER": ""}, + ) + print("S3 upload complete") + + +# --------------------------------------------------------------------------- +# Flows +# --------------------------------------------------------------------------- + + +@flow(name="build-linux-flow", log_prints=True) +def flow_build_linux() -> dict: + """Build all Linux distribution packages.""" + config = PipelineConfig() + return task_build_linux(config) + + +@flow(name="sign-flow", log_prints=True) +def flow_sign(gpg_passphrase: str) -> list[Path]: + """Sign all artifacts in the dist/ directory.""" + config = PipelineConfig() + return task_sign_artifacts(config.project_root / "dist", gpg_passphrase) + + +@flow(name="release-flow", log_prints=True) +def flow_release( + github_token: str, + s3_bucket: str = "", + run_id: str = "", +) -> None: + """Create a GitHub release and optionally upload to S3.""" + config = PipelineConfig() + version = task_get_version() + + release_dir = config.project_root / "release-files" + task_prepare_release_files(config.project_root / "dist", release_dir) + task_create_github_release(version, release_dir, github_token) + + if s3_bucket: + task_upload_s3(release_dir, s3_bucket, run_id or "local") + + +@flow(name="full-pipeline", log_prints=True) +def flow_full_pipeline( + gpg_passphrase: str = "", + github_token: str = "", + s3_bucket: str = "", + run_id: str = "", + skip_build: bool = False, + skip_sign: bool = False, + skip_release: bool = False, +) -> None: + """Run the complete CI/CD pipeline: build → sign → release → S3. + + Args: + gpg_passphrase: GPG key passphrase for signing. + github_token: GitHub token for release creation. + s3_bucket: Optional S3 bucket for artifact upload. + run_id: Build run identifier for S3 path. + skip_build: Skip the Linux build step. + skip_sign: Skip the signing step. + skip_release: Skip the release + S3 step. + """ + config = PipelineConfig() + + # Step 1: Build Linux distros + if not skip_build: + task_build_linux(config) + + # Step 2: Sign artifacts + if not skip_sign: + if not gpg_passphrase: + raise ValueError("--gpg-passphrase is required for signing") + task_sign_artifacts(config.project_root / "dist", gpg_passphrase) + + # Step 3: Release + if not skip_release: + if not github_token: + raise ValueError("--github-token is required for release") + version = task_get_version() + release_dir = config.project_root / "release-files" + task_prepare_release_files(config.project_root / "dist", release_dir) + task_create_github_release(version, release_dir, github_token) + + if s3_bucket: + task_upload_s3(release_dir, s3_bucket, run_id or "local") + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + + +def main() -> None: + """CLI entry point for running pipeline actions.""" + parser = argparse.ArgumentParser( + description="CI/CD pipeline orchestration via Prefect + Dagger" + ) + subparsers = parser.add_subparsers(dest="action", required=True) + + # build-linux + subparsers.add_parser("build-linux", help="Build all Linux distribution packages") + + # sign + sign_parser = subparsers.add_parser("sign", help="Sign artifacts in dist/") + sign_parser.add_argument("--gpg-passphrase", required=True, help="GPG passphrase") + + # release + release_parser = subparsers.add_parser("release", help="Create GitHub release") + release_parser.add_argument("--github-token", required=True, help="GitHub token") + release_parser.add_argument("--s3-bucket", default="", help="S3 bucket name") + release_parser.add_argument("--run-id", default="", help="Build run ID") + + # full + full_parser = subparsers.add_parser("full", help="Run full pipeline") + full_parser.add_argument("--gpg-passphrase", default="", help="GPG passphrase") + full_parser.add_argument("--github-token", default="", help="GitHub token") + full_parser.add_argument("--s3-bucket", default="", help="S3 bucket name") + full_parser.add_argument("--run-id", default="", help="Build run ID") + full_parser.add_argument("--skip-build", action="store_true") + full_parser.add_argument("--skip-sign", action="store_true") + full_parser.add_argument("--skip-release", action="store_true") + + args = parser.parse_args() + + if args.action == "build-linux": + flow_build_linux() + elif args.action == "sign": + flow_sign(gpg_passphrase=args.gpg_passphrase) + elif args.action == "release": + flow_release( + github_token=args.github_token, + s3_bucket=args.s3_bucket, + run_id=args.run_id, + ) + elif args.action == "full": + flow_full_pipeline( + gpg_passphrase=args.gpg_passphrase, + github_token=args.github_token, + s3_bucket=args.s3_bucket, + run_id=args.run_id, + skip_build=args.skip_build, + skip_sign=args.skip_sign, + skip_release=args.skip_release, + ) + + +if __name__ == "__main__": + main() diff --git a/ci/signing.py b/ci/signing.py new file mode 100644 index 0000000..2bb7040 --- /dev/null +++ b/ci/signing.py @@ -0,0 +1,90 @@ +"""GPG signing and SHA-256 hashing utilities for release artifacts.""" + +import hashlib +import subprocess +import tempfile +from pathlib import Path + + +def gpg_sign_file(file_path: Path, passphrase: str) -> Path: + """Create a detached ASCII-armored GPG signature for a file. + + Args: + file_path: Path to the file to sign. + passphrase: GPG key passphrase. + + Returns: + Path to the generated .asc signature file. + + Raises: + subprocess.CalledProcessError: If GPG signing fails. + FileNotFoundError: If the input file does not exist. + """ + if not file_path.exists(): + raise FileNotFoundError(f"File not found: {file_path}") + + sig_path = file_path.with_suffix(file_path.suffix + ".asc") + + with tempfile.NamedTemporaryFile(mode="w", suffix=".pass", delete=True) as passfile: + passfile.write(passphrase) + passfile.flush() + + subprocess.run( + [ + "gpg", "--batch", "--yes", + "--passphrase-file", passfile.name, + "--detach-sign", "--armor", + str(file_path), + ], + check=True, + capture_output=True, + text=True, + ) + + print(f"Signed: {sig_path}") + return sig_path + + +def sha256_hash_file(file_path: Path) -> tuple[str, Path]: + """Compute SHA-256 hash of a file and write a .sha256 checksum file. + + Args: + file_path: Path to the file to hash. + + Returns: + Tuple of (hex digest, path to .sha256 file). + + Raises: + FileNotFoundError: If the input file does not exist. + """ + if not file_path.exists(): + raise FileNotFoundError(f"File not found: {file_path}") + + sha256 = hashlib.sha256() + with open(file_path, "rb") as fh: + for chunk in iter(lambda: fh.read(8192), b""): + sha256.update(chunk) + + digest = sha256.hexdigest() + hash_line = f"{digest} {file_path.name}" + + hash_path = file_path.parent / f"{file_path.stem}.sha256" + hash_path.write_text(hash_line, encoding="ascii") + + print(f"SHA-256 ({file_path.name}): {digest}") + return digest, hash_path + + +def sign_and_hash(file_path: Path, passphrase: str) -> tuple[Path, Path]: + """Sign a file with GPG and generate its SHA-256 checksum. + + Args: + file_path: Path to the artifact to sign and hash. + passphrase: GPG key passphrase. + + Returns: + Tuple of (signature path, hash file path). + """ + sig_path = gpg_sign_file(file_path, passphrase) + _, hash_path = sha256_hash_file(file_path) + return sig_path, hash_path diff --git a/docker-compose.yml b/docker-compose.yml index e6484c7..5148f64 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,9 +1,18 @@ -# Docker Compose configuration for local multi-platform builds -# Matches the exact images and configurations from .github/workflows/release.yml +# DEPRECATED: This file is kept for backward compatibility. +# Use podman-compose.yml instead: +# podman-compose -f podman-compose.yml up --build +# +# Docker Compose will also read podman-compose.yml if you symlink: +# ln -sf podman-compose.yml docker-compose.yml +# +# --- Original configuration follows (mirrors podman-compose.yml) --- + +# Podman Compose / Docker Compose configuration for local multi-platform builds +# Matches the exact images and configurations from .github/workflows/release-prefect-dagger.yml # # Usage: -# Build all distributions: docker-compose up --build -# Build specific distro: docker-compose up --build debian +# Build all distributions: podman-compose up --build +# Build specific distro: podman-compose up --build debian # # Each service builds a distribution package and outputs to: # - dist/ - Final packaged files (.deb, .rpm, .pkg.tar.zst) diff --git a/podman-compose.yml b/podman-compose.yml new file mode 100644 index 0000000..86cad1a --- /dev/null +++ b/podman-compose.yml @@ -0,0 +1,72 @@ +# Podman Compose configuration for local multi-platform builds +# Compatible with podman-compose and docker-compose (via podman socket) +# +# Usage: +# Build all distributions: podman-compose up --build +# Build specific distro: podman-compose up --build debian +# +# Each service builds a distribution package and outputs to: +# - dist/ - Final packaged files (.deb, .rpm, .pkg.tar.zst) +# - pkg_dist_{distro}/ - Staging directory for package contents +# - dist_{distro}/ - PyInstaller build output + +services: + debian: + image: ghcr.io/jmr-dev/android-file-handler-debian-builder:debian13-trixie + build: + context: . + dockerfile: scripts/docker/Dockerfile.debian + args: + FPM_VERSION: "1.16.0" + volumes: + - .:/workspace:Z + # Exclude host .venv to prevent conflicts with container Python + - /workspace/.venv + working_dir: /workspace + environment: + - CI_CD=true + - DISTRO_TYPE=debian + - FPM_VERSION=1.16.0 + - POETRY_VIRTUALENVS_IN_PROJECT=false + - POETRY_VIRTUALENVS_PATH=/tmp/poetry-cache + command: sh -c "poetry install --no-interaction && poetry run python scripts/build_package_linux.py" + + arch: + image: ghcr.io/jmr-dev/android-file-handler-arch-builder:latest + build: + context: . + dockerfile: scripts/docker/Dockerfile.arch + args: + FPM_VERSION: "1.16.0" + volumes: + - .:/workspace:Z + # Exclude host .venv to prevent conflicts with container Python + - /workspace/.venv + working_dir: /workspace + environment: + - CI_CD=true + - DISTRO_TYPE=arch + - FPM_VERSION=1.16.0 + - POETRY_VIRTUALENVS_IN_PROJECT=false + - POETRY_VIRTUALENVS_PATH=/tmp/poetry-cache + command: sh -c "poetry install --no-interaction && poetry run python scripts/build_package_linux.py" + + rhel: + image: ghcr.io/jmr-dev/android-file-handler-rhel-builder:fedora42 + build: + context: . + dockerfile: scripts/docker/Dockerfile.rhel + args: + FPM_VERSION: "1.16.0" + volumes: + - .:/workspace:Z + # Exclude host .venv to prevent conflicts with container Python + - /workspace/.venv + working_dir: /workspace + environment: + - CI_CD=true + - DISTRO_TYPE=rhel + - FPM_VERSION=1.16.0 + - POETRY_VIRTUALENVS_IN_PROJECT=false + - POETRY_VIRTUALENVS_PATH=/tmp/poetry-cache + command: sh -c "poetry install --no-interaction && poetry run python scripts/build_package_linux.py" diff --git a/pyproject.toml b/pyproject.toml index 76c5bd3..f04a458 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,13 @@ pytest-cov = "^7.0.0" [tool.poetry.group.build.dependencies] pyinstaller = "^6.1.0" +[tool.poetry.group.ci] +optional = true + +[tool.poetry.group.ci.dependencies] +dagger-io = ">=0.15.0" +prefect = ">=3.0.0" + [tool.black] line-length = 88 target-version = ['py313'] diff --git a/scripts/build_package_linux.py b/scripts/build_package_linux.py index 4f6198c..6846c78 100755 --- a/scripts/build_package_linux.py +++ b/scripts/build_package_linux.py @@ -7,7 +7,7 @@ import sys import re from pathlib import Path from enum import Enum -from typing import List +from typing import List, TypedDict class DistroType(Enum): @@ -16,7 +16,19 @@ class DistroType(Enum): RHEL = "rhel" -def run_command(cmd: list[str], check: bool = True, working_dir: str | None = None) -> subprocess.CompletedProcess: +class DistroConfigDict(TypedDict): + """Type definition for distro configuration.""" + + name: str + bin_path: str + pkg_suffix: str + spec_file: str + pkg_type: str + architecture: str + postinstall: str | None + + +def run_command(cmd: list[str], check: bool = True, working_dir: str | None = None) -> subprocess.CompletedProcess[bytes]: """Run command and handle errors.""" print(f"Running: {' '.join(cmd)}") try: @@ -29,9 +41,9 @@ def run_command(cmd: list[str], check: bool = True, working_dir: str | None = No sys.exit(1) -def get_distro_config(distro_type: DistroType) -> dict: +def get_distro_config(distro_type: DistroType) -> DistroConfigDict: """Get configuration for specific distro type.""" - configs = { + configs: dict[DistroType, DistroConfigDict] = { DistroType.DEBIAN: { "name": "Debian", "bin_path": "usr/local/bin", @@ -131,10 +143,11 @@ def package_with_fpm(distro_type: DistroType, version: str, project_root: Path) # Add distro-specific options if distro_type == DistroType.DEBIAN: output_file = dist_dir / f"android-file-handler_{version}_{config['architecture']}.deb" + postinstall = config["postinstall"] fpm_cmd.extend([ "--deb-user", "root", "--deb-group", "root", - "--after-install", config["postinstall"], + *(["--after-install", postinstall] if postinstall else []), "-p", str(output_file) ]) elif distro_type == DistroType.ARCH: @@ -144,9 +157,9 @@ def package_with_fpm(distro_type: DistroType, version: str, project_root: Path) ]) elif distro_type == DistroType.RHEL: output_file = dist_dir / f"android-file-handler-{version}.{config['architecture']}.rpm" + postinstall = config["postinstall"] fpm_cmd.extend([ - "--prefix", "/usr/bin", - "--after-install", config["postinstall"], + *(["--after-install", postinstall] if postinstall else []), "-p", str(output_file) ]) diff --git a/scripts/docker/README.md b/scripts/docker/README.md index 3133c8f..72c0ebf 100644 --- a/scripts/docker/README.md +++ b/scripts/docker/README.md @@ -1,52 +1,57 @@ -# Docker Build Environment +# Container Build Environment -This directory contains Dockerfiles for building the Android File Handler on different Linux distributions. These images match exactly the images used in the CI/CD pipeline. +This directory contains OCI-compatible Containerfiles (Dockerfiles) for building the Android File Handler on different Linux distributions. These images match exactly the images used in the CI/CD pipeline. + +The project uses **Podman** as the container runtime. All commands below use Podman; if you have Docker installed, the Dockerfiles are OCI-compatible and will work with Docker as well. ## Quick Start -### Using Docker Compose (Recommended) +### Using Podman Compose (Recommended) Build for all distributions: ```bash -docker-compose up --build +podman-compose up --build ``` Build for a specific distribution: ```bash -docker-compose up --build debian -docker-compose up --build arch -docker-compose up --build rhel +podman-compose up --build debian +podman-compose up --build arch +podman-compose up --build rhel ``` -Build all distributions in parallel: +### Using Prefect + Dagger (CI Pipeline Locally) + +The CI/CD pipeline uses Prefect and Dagger to orchestrate builds. You can run it locally: ```bash -docker-compose up --build --parallel +poetry install --with ci +poetry run python -m ci.prefect_flow build-linux ``` -### Manual Docker Build +### Manual Podman Build Build the image: ```bash # Debian -docker build -f scripts/docker/Dockerfile.debian -t android-file-handler-debian-builder . +podman build -f scripts/docker/Dockerfile.debian -t android-file-handler-debian-builder . # Arch -docker build -f scripts/docker/Dockerfile.arch -t android-file-handler-arch-builder . +podman build -f scripts/docker/Dockerfile.arch -t android-file-handler-arch-builder . # RHEL/Fedora -docker build -f scripts/docker/Dockerfile.rhel -t android-file-handler-rhel-builder . +podman build -f scripts/docker/Dockerfile.rhel -t android-file-handler-rhel-builder . ``` Run the build: ```bash # Debian -docker run --rm -v $(pwd):/workspace -w /workspace android-file-handler-debian-builder +podman run --rm -v $(pwd):/workspace:Z -w /workspace android-file-handler-debian-builder # Arch -docker run --rm -v $(pwd):/workspace -w /workspace android-file-handler-arch-builder +podman run --rm -v $(pwd):/workspace:Z -w /workspace android-file-handler-arch-builder # RHEL/Fedora -docker run --rm -v $(pwd):/workspace -w /workspace android-file-handler-rhel-builder +podman run --rm -v $(pwd):/workspace:Z -w /workspace android-file-handler-rhel-builder ``` ## Output @@ -81,9 +86,13 @@ After building, you'll find: ### Virtualenv Conflicts -The Docker Compose configuration automatically excludes the host's `.venv` directory to prevent conflicts between the host Python environment and the container Python environment. Each container creates its own virtualenv in `/tmp/poetry-cache`. +The Podman Compose configuration automatically excludes the host's `.venv` directory to prevent conflicts between the host Python environment and the container Python environment. Each container creates its own virtualenv in `/tmp/poetry-cache`. -If you encounter virtualenv-related errors, ensure you're using the latest docker-compose.yml configuration. +If you encounter virtualenv-related errors, ensure you're using the latest podman-compose.yml configuration. + +### SELinux (Fedora/RHEL hosts) + +Volume mounts use the `:Z` suffix to apply the correct SELinux labels automatically. If you encounter permission errors, ensure the `:Z` suffix is present on volume mounts. ## Cleaning Up @@ -92,9 +101,9 @@ Remove build artifacts: rm -rf dist pkg_dist_* dist_* ``` -Remove Docker volumes and containers: +Remove Podman containers and volumes: ```bash -docker compose down -v +podman-compose down -v ``` ## Customization diff --git a/scripts/rhel_postinst.sh b/scripts/rhel_postinst.sh index 19307d2..38037a5 100644 --- a/scripts/rhel_postinst.sh +++ b/scripts/rhel_postinst.sh @@ -19,7 +19,7 @@ if command -v gtk-update-icon-cache >/dev/null 2>&1; then fi # Ensure installed binary is executable -if [ -f /usr/local/bin/android-file-handler ]; then +if [ -f /usr/bin/android-file-handler ]; then chmod 0755 /usr/bin/android-file-handler || true fi diff --git a/scripts/spec_scripts/android-file-handler-windows.spec b/scripts/spec_scripts/android-file-handler-windows.spec index 480a86d..41c7197 100644 --- a/scripts/spec_scripts/android-file-handler-windows.spec +++ b/scripts/spec_scripts/android-file-handler-windows.spec @@ -5,8 +5,7 @@ a = Analysis( pathex=['../..'], binaries=[], datas=[ - ('../../src/gui', 'gui'), - ('../windows/first_run_install.ps1', 'scripts/windows') + ('../../src/gui', 'gui'), ], hiddenimports=[ # GUI modules diff --git a/scripts/windows/android-file-handler-setup.iss b/scripts/windows/android-file-handler-setup.iss new file mode 100644 index 0000000..7e9391f --- /dev/null +++ b/scripts/windows/android-file-handler-setup.iss @@ -0,0 +1,56 @@ +; Inno Setup script for Android File Handler +; Compiles a Windows installer from the PyInstaller one-file executable. +; +; Usage (CI): +; iscc scripts\windows\android-file-handler-setup.iss /DMyAppVersion=1.2.3 +; +; Usage (local, from repo root): +; "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" scripts\windows\android-file-handler-setup.iss + +#ifndef MyAppVersion + #define MyAppVersion "0.1.1" +#endif + +#define MyAppName "Android File Handler" +#define MyAppPublisher "Jason Ross" +#define MyAppURL "https://github.com/JMR-dev/android-file-handler" +#define MyAppExeName "android-file-handler-windows.exe" + +[Setup] +AppId={{8F2B3A7E-4D1C-4E8F-9A2B-6C7D8E9F0A1B} +AppName={#MyAppName} +AppVersion={#MyAppVersion} +AppPublisher={#MyAppPublisher} +AppPublisherURL={#MyAppURL} +AppSupportURL={#MyAppURL} +AppUpdatesURL={#MyAppURL} +DefaultDirName={autopf}\{#MyAppName} +DefaultGroupName={#MyAppName} +LicenseFile=..\..\LICENSE.txt +OutputDir=..\..\dist +OutputBaseFilename=android-file-handler-setup +SetupIconFile=..\..\icon_media\robot_files_256.ico +UninstallDisplayIcon={app}\{#MyAppExeName} +Compression=lzma2/ultra64 +SolidCompression=yes +WizardStyle=modern +ArchitecturesInstallIn64BitMode=x64compatible +PrivilegesRequired=admin +MinVersion=10.0 + +[Languages] +Name: "english"; MessagesFile: "compiler:Default.isl" + +[Tasks] +Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked + +[Files] +Source: "..\..\dist\{#MyAppExeName}"; DestDir: "{app}"; Flags: ignoreversion + +[Icons] +Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}" +Name: "{group}\{cm:UninstallProgram,{#MyAppName}}"; Filename: "{uninstallexe}" +Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon + +[Run] +Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent diff --git a/scripts/windows/first_run_install.ps1 b/scripts/windows/first_run_install.ps1 deleted file mode 100644 index 55c4c64..0000000 --- a/scripts/windows/first_run_install.ps1 +++ /dev/null @@ -1,51 +0,0 @@ -param( - [string]$ExePath = "$PSScriptRoot\..\..\dist\android-file-handler.exe", - [string]$IconPath = "$PSScriptRoot\..\..\assets\icons\android-file-handler.ico", - [string]$AppName = "Android File Handler" -) - -function Ensure-Elevated { - if (-not ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole] "Administrator")) { - # Relaunch the script with elevation - $psi = New-Object System.Diagnostics.ProcessStartInfo - $psi.FileName = "powershell.exe" - $psi.Arguments = "-ExecutionPolicy Bypass -File `"$PSCommandPath`"" - $psi.Verb = "runas" - try { - [System.Diagnostics.Process]::Start($psi) | Out-Null - Exit 0 - } catch { - Write-Error "Elevation required to install to Program Files." - Exit 1 - } - } -} - -Ensure-Elevated - -$destDir = Join-Path ${env:ProgramFiles} $AppName -if (-not (Test-Path $destDir)) { New-Item -ItemType Directory -Path $destDir | Out-Null } - -$resolvedExe = Resolve-Path -Path $ExePath -ErrorAction SilentlyContinue -if (-not $resolvedExe) { - Write-Error "Application executable not found at $ExePath" - Exit 1 -} - -Copy-Item -Path $resolvedExe -Destination (Join-Path $destDir (Split-Path $resolvedExe -Leaf)) -Force - -# Create Start Menu shortcut -$programs = Join-Path $env:APPDATA 'Microsoft\Windows\Start Menu\Programs' -$appFolder = Join-Path $programs $AppName -if (-not (Test-Path $appFolder)) { New-Item -ItemType Directory -Path $appFolder | Out-Null } - -$shortcutPath = Join-Path $appFolder "$AppName.lnk" -$wsh = New-Object -ComObject WScript.Shell -$sc = $wsh.CreateShortcut($shortcutPath) -$sc.TargetPath = (Join-Path $destDir (Split-Path $resolvedExe -Leaf)) -$sc.WorkingDirectory = $destDir -if (Test-Path $IconPath) { $sc.IconLocation = Resolve-Path $IconPath } -$sc.Save() - -Write-Output "Installed $AppName to $destDir and created Start Menu shortcut." -Exit 0 diff --git a/src/gui/dialogs/license_agreement.py b/src/gui/dialogs/license_agreement.py index efb4731..e7dfca9 100644 --- a/src/gui/dialogs/license_agreement.py +++ b/src/gui/dialogs/license_agreement.py @@ -9,9 +9,6 @@ import tkinter as tk from tkinter import messagebox, scrolledtext import tempfile import stat -import subprocess -import sys -import os def get_license_file_path() -> str: @@ -107,48 +104,6 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.""" -def resource_path(relative_path: str) -> str: - """Return absolute path to resource for dev and frozen runs.""" - try: - if getattr(sys, "frozen", False): - base = getattr(sys, "_MEIPASS", os.path.dirname(sys.executable)) - else: - base = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - return os.path.normpath(os.path.join(base, relative_path)) - except Exception: - return os.path.normpath( - os.path.join(os.path.dirname(os.path.abspath(__file__)), relative_path) - ) - - -def run_windows_first_run_if_needed() -> None: - """If running on Windows and license not agreed, launch first-run installer script. - - Uses `resource_path` to locate the bundled PowerShell script in both dev and frozen modes. - """ - try: - if not sys.platform.startswith("win"): - return - - if check_license_agreement(): - return - - script_rel = os.path.join("scripts", "windows", "first_run_install.ps1") - script_path = resource_path(script_rel) - if not os.path.exists(script_path): - return - try: - subprocess.Popen( - ["powershell.exe", "-ExecutionPolicy", "Bypass", "-File", script_path], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - except Exception: - pass - except Exception: - pass - - class LicenseAgreementFrame(tk.Frame): """License agreement UI frame that can be embedded in the main window.""" diff --git a/src/main.py b/src/main.py index 3fdd9b0..a1ac47f 100644 --- a/src/main.py +++ b/src/main.py @@ -6,12 +6,9 @@ Simple entry point to launch the Android file transfer application. try: from gui.main_window import main - from gui.dialogs.license_agreement import run_windows_first_run_if_needed except ImportError: from .gui.main_window import main - from .gui.dialogs.license_agreement import run_windows_first_run_if_needed if __name__ == "__main__": - run_windows_first_run_if_needed() main()